dsh-standards 1.5.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.
Files changed (77) hide show
  1. package/40-checker/fixtures/security/sec15-clean/index.mjs +3 -0
  2. package/40-checker/fixtures/security/sec15-clean/package.json +19 -0
  3. package/40-checker/fixtures/security/sec15-install-cred/index.mjs +7 -0
  4. package/40-checker/fixtures/security/sec15-install-cred/install.js +8 -0
  5. package/40-checker/fixtures/security/sec15-install-cred/package.json +22 -0
  6. package/40-checker/fixtures/security/sec15-install-curl/index.mjs +7 -0
  7. package/40-checker/fixtures/security/sec15-install-curl/package.json +22 -0
  8. package/40-checker/fixtures/security/sec15-install-curl/scripts/install.sh +4 -0
  9. package/40-checker/fixtures/security/sec15-install-danger/index.mjs +7 -0
  10. package/40-checker/fixtures/security/sec15-install-danger/install.js +5 -0
  11. package/40-checker/fixtures/security/sec15-install-danger/package.json +22 -0
  12. package/40-checker/fixtures/security/sec15-install-safe/index.mjs +7 -0
  13. package/40-checker/fixtures/security/sec15-install-safe/install.js +5 -0
  14. package/40-checker/fixtures/security/sec15-install-safe/package.json +22 -0
  15. package/40-checker/fixtures/security/sec15-missing-ref/index.mjs +3 -0
  16. package/40-checker/fixtures/security/sec15-missing-ref/package.json +22 -0
  17. package/40-checker/fixtures/security/sec15-node/index.mjs +3 -0
  18. package/40-checker/fixtures/security/sec15-node/package.json +22 -0
  19. package/40-checker/fixtures/security/sec15-node/scripts/build.mjs +1 -0
  20. package/40-checker/fixtures/security/sec15-pipe/index.mjs +3 -0
  21. package/40-checker/fixtures/security/sec15-pipe/package.json +22 -0
  22. package/40-checker/fixtures/security/sec16-client/index.mjs +3 -0
  23. package/40-checker/fixtures/security/sec16-client/lib/client.js +3 -0
  24. package/40-checker/fixtures/security/sec16-client/package.json +21 -0
  25. package/40-checker/fixtures/security/sec16-declared/index.mjs +3 -0
  26. package/40-checker/fixtures/security/sec16-declared/package.json +19 -0
  27. package/40-checker/fixtures/security/sec16-eval/index.mjs +3 -0
  28. package/40-checker/fixtures/security/sec16-eval/package.json +19 -0
  29. package/40-checker/fixtures/security/sec16-require/index.mjs +4 -0
  30. package/40-checker/fixtures/security/sec16-require/package.json +19 -0
  31. package/40-checker/fixtures/security/sec17-cred-exfil/index.mjs +22 -0
  32. package/40-checker/fixtures/security/sec17-cred-exfil/package.json +19 -0
  33. package/40-checker/fixtures/security/sec17-env/index.mjs +5 -0
  34. package/40-checker/fixtures/security/sec17-env/package.json +19 -0
  35. package/40-checker/fixtures/security/sec17-seam/index.mjs +3 -0
  36. package/40-checker/fixtures/security/sec17-seam/package.json +19 -0
  37. package/40-checker/fixtures/security/sec17-ssh/index.mjs +6 -0
  38. package/40-checker/fixtures/security/sec17-ssh/package.json +19 -0
  39. package/40-checker/fixtures/security/sec18-bad/index.mjs +3 -0
  40. package/40-checker/fixtures/security/sec18-bad/package.json +21 -0
  41. package/40-checker/fixtures/security/sec18-good/index.mjs +3 -0
  42. package/40-checker/fixtures/security/sec18-good/package.json +26 -0
  43. package/40-checker/fixtures/security/sec18-undeclared/index.mjs +3 -0
  44. package/40-checker/fixtures/security/sec18-undeclared/package.json +26 -0
  45. package/40-checker/fixtures/security/sec19-blob/index.mjs +2 -0
  46. package/40-checker/fixtures/security/sec19-blob/package.json +19 -0
  47. package/40-checker/fixtures/security/sec19-clean/index.mjs +2 -0
  48. package/40-checker/fixtures/security/sec19-clean/package.json +19 -0
  49. package/40-checker/fixtures/security/sec19-wasm/blob.wasm +0 -0
  50. package/40-checker/fixtures/security/sec19-wasm/index.mjs +3 -0
  51. package/40-checker/fixtures/security/sec19-wasm/package.json +19 -0
  52. package/40-checker/fixtures/security/sec20-unwrapped/index.mjs +4 -0
  53. package/40-checker/fixtures/security/sec20-unwrapped/package.json +23 -0
  54. package/40-checker/fixtures/security/sec20-wrapped/index.mjs +3 -0
  55. package/40-checker/fixtures/security/sec20-wrapped/package.json +23 -0
  56. package/40-checker/fixtures/security/sec21-known/index.mjs +3 -0
  57. package/40-checker/fixtures/security/sec21-known/package.json +23 -0
  58. package/40-checker/fixtures/security/sec21-typosquat/index.mjs +3 -0
  59. package/40-checker/fixtures/security/sec21-typosquat/package.json +22 -0
  60. package/40-checker/fixtures/security/sec22-attested/index.mjs +3 -0
  61. package/40-checker/fixtures/security/sec22-attested/package.json +22 -0
  62. package/40-checker/fixtures/security/sec22-none/index.mjs +3 -0
  63. package/40-checker/fixtures/security/sec22-none/package.json +19 -0
  64. package/40-checker/src/cli.mjs +8 -3
  65. package/40-checker/src/declarations.mjs +10 -10
  66. package/40-checker/src/index.mjs +2 -1
  67. package/40-checker/src/report.mjs +68 -2
  68. package/40-checker/src/security.mjs +1353 -0
  69. package/40-checker/src/util.mjs +53 -0
  70. package/CHANGELOG.md +25 -0
  71. package/docs/SIGNING.md +137 -0
  72. package/docs/coverage.md +39 -6
  73. package/package.json +1 -1
  74. package/spec/coverage-contract.md +33 -11
  75. package/spec/dsc-clauses.json +410 -9
  76. package/spec/remediation.json +196 -0
  77. package/spec/schemas/conformance.schema.json +75 -2
@@ -0,0 +1,1353 @@
1
+ /**
2
+ * Security layer v1: DSC-15…DSC-22.
3
+ *
4
+ * These checks are static clues, not a security audit. They cover:
5
+ * 15 install lifecycle scripts (blocking in --scope security)
6
+ * 16 dynamic execution (blocking)
7
+ * 17 sensitive-file reads (blocking)
8
+ * 18 egress endpoints (advisory)
9
+ * 19 opaque/binary artifacts (advisory)
10
+ * 20 prompt-injection clues (advisory)
11
+ * 21 supply-chain name similarity (blocking in --scope security, WARN only)
12
+ * 22 provenance/integrity (advisory, not-run without metadata)
13
+ *
14
+ * None of these checks can prove the absence of malicious code.
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { createHash } from 'node:crypto'
19
+ import { check, detail, worst } from './result.mjs'
20
+ import { inventoryFiles } from './util.mjs'
21
+ import {
22
+ collectModuleEdges,
23
+ identifierName,
24
+ isDeclarationFile,
25
+ literalStringValue,
26
+ memberPropertyName,
27
+ normalizeRelative,
28
+ parseSource,
29
+ resolveLocalSpecifier,
30
+ walkAst,
31
+ } from './declarations.mjs'
32
+
33
+ export const SECURITY_IDS = [
34
+ 'DSC-15', 'DSC-16', 'DSC-17', 'DSC-18', 'DSC-19', 'DSC-20', 'DSC-21', 'DSC-22',
35
+ ]
36
+
37
+ const SECURITY_ADVISORY = new Set(['DSC-18', 'DSC-19', 'DSC-20', 'DSC-22'])
38
+ const SECURITY_CLAUSE = {
39
+ 'DSC-15': 'C-15', 'DSC-16': 'C-16', 'DSC-17': 'C-17', 'DSC-18': 'C-18',
40
+ 'DSC-19': 'C-19', 'DSC-20': 'C-20', 'DSC-21': 'C-21', 'DSC-22': 'C-22',
41
+ }
42
+
43
+ const READ_APIS = new Set([
44
+ 'readFile', 'readFileSync', 'readJson', 'readJsonSync', 'readYaml', 'readYamlSync',
45
+ 'open', 'openSync', 'createReadStream', 'readdir', 'readdirSync',
46
+ ])
47
+ const CHILD_PROCESS = /^(?:node:)?child_process$/
48
+ 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
+ const SUSPICIOUS_HOST = /(?:^|\.)(?:webhook|pastebin|paste|ngrok\.io|ngrok-free\.app|requestbin|pipedream|interact\.sh|burpcollaborator|canarytokens)\./i
50
+ const TEXT_EXTENSIONS = /\.(?:[cm]?[jt]sx?|json|md|txt|ya?ml|html|css)$/i
51
+ const BINARY_EXTENSIONS = new Set(['.wasm', '.node', '.so', '.dll', '.exe', '.dylib'])
52
+ const RULE_FILE = /(?:^|\/)(?:rules?|guards?|defend|detect|policy|policies|patterns?)(?:[./-]|$)/i
53
+ const RULE_KEYS = new Set(['rules', 'rule', 'patterns', 'pattern', 'regex', 'regexes', 'guard', 'guards', 'deny', 'blocklist', 'allowlist'])
54
+ const DOC_KEYS = new Set(['desc', 'description', 'message', 'title', 'label', 'reason', 'comment', 'help'])
55
+
56
+ const LIFECYCLE_NAMES = ['preinstall', 'install', 'postinstall', 'prepare']
57
+ const DANGEROUS_SCRIPT = [
58
+ { pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
59
+ { pattern: /\bbase64\s+(?:-d|--decode)\b|\bbase64\s+-D\b/, label: 'base64 decode in an install script' },
60
+ { pattern: /\bnode\s+(?:-e|--eval)\b|\bnode(?:\.exe)?\s+['"]?-e/, label: 'node -e in an install script' },
61
+ { pattern: /\b(?:sh|bash|zsh|dash|ksh)\s+-[a-z]*c\b/, label: 'shell -c in an install script' },
62
+ { pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
63
+ { pattern: /\$HOME\b|\$\{HOME\}|\$DSH_HOME\b|\$\{DSH_HOME\}/, label: 'install script references the home directory' },
64
+ { pattern: /(?:^|[\s'"=:(])~\/|(?:^|[\s'"=:(])\/etc\//, label: 'install script references ~ or /etc' },
65
+ ]
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // small AST helpers shared by the checks below
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function keyName(property) {
72
+ if (!property || property.type !== 'Property') return null
73
+ if (!property.computed && property.key?.type === 'Identifier') return property.key.name
74
+ if (property.key?.type === 'Literal' && typeof property.key.value === 'string') return property.key.value
75
+ return null
76
+ }
77
+
78
+ function chainNames(node) {
79
+ const names = []
80
+ let current = node
81
+ for (let guard = 0; current && guard < 12; guard += 1) {
82
+ if (current.type === 'Identifier') { names.unshift(current.name); break }
83
+ if (current.type === 'MemberExpression') {
84
+ const property = memberPropertyName(current)
85
+ if (property === null) break
86
+ names.unshift(property)
87
+ current = current.object
88
+ continue
89
+ }
90
+ if (current.type === 'CallExpression' || current.type === 'NewExpression') {
91
+ current = current.callee
92
+ continue
93
+ }
94
+ break
95
+ }
96
+ return names
97
+ }
98
+
99
+ function isClientRelative(relative) {
100
+ return /(?:^|\/)(?:client|browser)(?:\.(?:[cm]?[jt]sx?)|(?:\/))/i.test(normalizeRelative(relative))
101
+ }
102
+
103
+ function serverSources(sources) {
104
+ return (sources ?? []).filter(source => !isDeclarationFile(source.relative) && !isClientRelative(source.relative))
105
+ }
106
+
107
+ function lineOf(node) {
108
+ return node?.loc?.start?.line ?? 1
109
+ }
110
+
111
+ function evidenceOf(file, node) {
112
+ return `${normalizeRelative(file)}:${lineOf(node)}`
113
+ }
114
+
115
+ function collectBindings(ast) {
116
+ const bindings = new Map()
117
+ walkAst(ast, (node) => {
118
+ if (node.type !== 'VariableDeclarator') return
119
+ if (node.id?.type !== 'Identifier' || !node.init) return
120
+ if (!bindings.has(node.id.name)) bindings.set(node.id.name, node.init)
121
+ })
122
+ return bindings
123
+ }
124
+
125
+ function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
126
+ if (!node || depth > 5 || seen.has(node)) return null
127
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value
128
+ if (node.type === 'TemplateLiteral') {
129
+ let text = ''
130
+ for (let index = 0; index < node.quasis.length; index += 1) {
131
+ text += node.quasis[index].value?.cooked ?? node.quasis[index].value?.raw ?? ''
132
+ if (index < node.expressions.length) {
133
+ const part = stringFromNode(node.expressions[index], bindings, depth + 1, new Set(seen).add(node))
134
+ if (part === null) return null
135
+ text += part
136
+ }
137
+ }
138
+ return text
139
+ }
140
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
141
+ const left = stringFromNode(node.left, bindings, depth + 1, new Set(seen).add(node))
142
+ const right = stringFromNode(node.right, bindings, depth + 1, new Set(seen).add(node))
143
+ if (left === null || right === null) return null
144
+ return left + right
145
+ }
146
+ if (node.type === 'ConditionalExpression') {
147
+ return stringFromNode(node.consequent, bindings, depth + 1, new Set(seen).add(node))
148
+ ?? stringFromNode(node.alternate, bindings, depth + 1, new Set(seen).add(node))
149
+ }
150
+ if (node.type === 'LogicalExpression') {
151
+ return stringFromNode(node.left, bindings, depth + 1, new Set(seen).add(node))
152
+ ?? stringFromNode(node.right, bindings, depth + 1, new Set(seen).add(node))
153
+ }
154
+ if (node.type === 'Identifier') {
155
+ if (node.name === 'HOME') return '/home/dsh-user'
156
+ const init = bindings.get(node.name)
157
+ if (!init) return null
158
+ return stringFromNode(init, bindings, depth + 1, new Set(seen).add(node))
159
+ }
160
+ if (node.type === 'MemberExpression') {
161
+ const property = memberPropertyName(node)
162
+ const objectName = identifierName(node.object)
163
+ 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'
170
+ return null
171
+ }
172
+ if (node.type === 'CallExpression') {
173
+ const names = chainNames(node.callee)
174
+ const last = names[names.length - 1]
175
+ if (last === 'cwd') return '/work/package'
176
+ if (last === 'homedir') return '/home/dsh-user'
177
+ if (['join', 'resolve', 'normalize'].includes(last)) {
178
+ const object = node.callee.type === 'MemberExpression' ? node.callee.object : null
179
+ const fromArray = object?.type === 'ArrayExpression' ? object.elements : null
180
+ if (fromArray) {
181
+ const parts = fromArray.map(item => stringFromNode(item, bindings, depth + 1, new Set(seen).add(node)))
182
+ if (parts.some(part => part === null)) return null
183
+ return parts.join('/')
184
+ }
185
+ const parts = node.arguments.map(item => stringFromNode(item, bindings, depth + 1, new Set(seen).add(node)))
186
+ if (parts.length === 0 || parts.some(part => part === null)) return null
187
+ return parts.join('/')
188
+ }
189
+ if (last === 'join' && node.callee.type === 'MemberExpression' && node.callee.object.type === 'ArrayExpression') {
190
+ const parts = node.callee.object.elements.map(item => stringFromNode(item, bindings, depth + 1, new Set(seen).add(node)))
191
+ if (parts.some(part => part === null)) return null
192
+ const separator = node.arguments.length > 0 ? stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node)) : '/'
193
+ return parts.join(separator ?? '/')
194
+ }
195
+ if (last === 'toString') {
196
+ const object = node.callee.type === 'MemberExpression' ? node.callee.object : null
197
+ if (object?.type === 'Literal') return String(object.value)
198
+ }
199
+ if (names[0] === 'String') return stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
200
+ return null
201
+ }
202
+ return null
203
+ }
204
+
205
+ function parseModule(source) {
206
+ const parsed = parseSource(source)
207
+ if (parsed.error) return { ast: null, error: parsed.error, bindings: new Map() }
208
+ return { ast: parsed.ast, error: null, bindings: collectBindings(parsed.ast) }
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // import-closure walker (DSC-16 only; DSC-10 has its own side-effect walker)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ function analyseClosure(sources, entryRelative) {
216
+ const byRelative = new Map()
217
+ for (const source of serverSources(sources)) byRelative.set(normalizeRelative(source.relative), source)
218
+ const entry = entryRelative ? normalizeRelative(entryRelative) : null
219
+ const queue = []
220
+ const notes = []
221
+ if (entry && byRelative.has(entry)) {
222
+ queue.push(entry)
223
+ } else if (entry) {
224
+ notes.push({ item: 'C-16.4', result: 'NA', reason: `not-run: entry module ${entryRelative} is not a scanned server source; scanned every server source instead`, evidence: [normalizeRelative(entryRelative)] })
225
+ for (const relative of byRelative.keys()) queue.push(relative)
226
+ } else {
227
+ notes.push({ item: 'C-16.4', result: 'NA', reason: 'not-run: no resolvable entry; scanned every server source instead', evidence: [] })
228
+ for (const relative of byRelative.keys()) queue.push(relative)
229
+ }
230
+ const visited = new Set()
231
+ const parseErrors = []
232
+ const packageImports = []
233
+ const unresolved = []
234
+ while (queue.length > 0) {
235
+ const relative = queue.shift()
236
+ if (visited.has(relative)) continue
237
+ visited.add(relative)
238
+ const parsed = parseModule(byRelative.get(relative))
239
+ if (parsed.error) {
240
+ parseErrors.push({ file: relative, ...parsed.error })
241
+ continue
242
+ }
243
+ for (const edge of collectModuleEdges(parsed.ast)) {
244
+ const resolution = resolveLocalSpecifier(relative, edge.specifier, byRelative)
245
+ if (resolution.kind === 'local') {
246
+ if (!isClientRelative(resolution.relative)) queue.push(resolution.relative)
247
+ } else if (resolution.kind === 'package') {
248
+ packageImports.push({ file: relative, line: edge.line, specifier: edge.specifier })
249
+ } else {
250
+ unresolved.push({ file: relative, line: edge.line, specifier: edge.specifier })
251
+ }
252
+ }
253
+ }
254
+ return { visited, parseErrors, packageImports, unresolved, notes, byRelative }
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // DSC-15: install lifecycle scripts
259
+ // ---------------------------------------------------------------------------
260
+
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
+ }
312
+ for (const match of command.matchAll(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:.@-]+)/g)) {
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)
321
+ }
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) }
346
+ }
347
+
348
+ export function checkLifecycleScripts(manifest, dir) {
349
+ const scripts = manifest?.scripts && typeof manifest.scripts === 'object' && !Array.isArray(manifest.scripts) ? manifest.scripts : {}
350
+ const details = []
351
+ const findings = []
352
+ let present = 0
353
+ for (const name of LIFECYCLE_NAMES) {
354
+ const command = scripts[name]
355
+ if (typeof command !== 'string' || command.trim() === '') continue
356
+ present += 1
357
+ const evidence = [`package.json#scripts.${name}`]
358
+ const dangerous = DANGEROUS_SCRIPT.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
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
+ }
369
+ if (name === 'prepare' && dangerous.length > 0) {
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))
371
+ findings.push('warn')
372
+ } else if (dangerous.length > 0) {
373
+ details.push(detail('C-15.1', 'FAIL', `scripts.${name} uses a dangerous form: ${dangerous.join('; ')}`, evidence))
374
+ findings.push('fail')
375
+ } else if (missing.length > 0) {
376
+ // `prepare` also runs for git/source installs, which pnpm gates behind
377
+ // allowBuilds; a missing prepare target is therefore WARN, not FAIL.
378
+ const level = name === 'prepare' ? 'WARN' : 'FAIL'
379
+ details.push(detail('C-15.3', level, `scripts.${name} references missing script(s): ${missing.join(', ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
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')
388
+ } else {
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
+ }
393
+ findings.push('warn')
394
+ }
395
+ }
396
+ if (present === 0) {
397
+ details.push(detail('C-15.2', 'PASS', 'no preinstall/install/postinstall/prepare script is declared'))
398
+ }
399
+ const result = worst(findings.map(value => value === 'fail' ? 'FAIL' : 'WARN'))
400
+ return check({
401
+ id: 'DSC-15',
402
+ clause: 'C-15',
403
+ layer: 'L0',
404
+ result: findings.length === 0 ? 'PASS' : result,
405
+ coverage: 'full',
406
+ reason: details.filter(item => item.result === 'FAIL' || item.result === 'WARN').map(item => `${item.item}: ${item.reason}`).join(' | '),
407
+ details,
408
+ evidence: ['package.json#scripts'],
409
+ })
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // DSC-16: dynamic execution in the server entry closure
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function allowsEvalContext(ancestors) {
417
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
418
+ const node = ancestors[index]
419
+ if (node.type === 'Property' && keyName(node) === 'allowsEval') return true
420
+ if (node.type === 'VariableDeclarator' && identifierName(node.id) === 'allowsEval') return true
421
+ if (node.type === 'AssignmentExpression' && memberPropertyName(node.left) === 'allowsEval') return true
422
+ }
423
+ return false
424
+ }
425
+
426
+ function isFunctionConstructor(node) {
427
+ if (node.type !== 'NewExpression' && node.type !== 'CallExpression') return false
428
+ return chainNames(node.callee).join('.') === 'Function'
429
+ }
430
+
431
+ function functionConstructorExempt(node, ancestors) {
432
+ const args = node.arguments ?? []
433
+ if (args.length === 0) return true
434
+ if (args.every(arg => arg.type === 'Literal' && arg.value === '')) return true
435
+ return allowsEvalContext(ancestors)
436
+ }
437
+
438
+ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
439
+ if (kind === 'client') {
440
+ return check({
441
+ id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
442
+ reason: 'client-only package; server entry closure is not applicable',
443
+ details: [detail('C-16.4', 'NA', 'not-run: client package has no server entry', ['package.json#dsh.kind'])],
444
+ evidence: ['package.json#dsh.kind'],
445
+ })
446
+ }
447
+ if (!sources || sources.length === 0) {
448
+ return check({
449
+ id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
450
+ reason: 'no source files scanned; DSC-16 not-run',
451
+ details: [detail('C-16.4', 'NA', 'not-run: no source files', [])],
452
+ evidence: [],
453
+ })
454
+ }
455
+ if (serverSources(sources).length === 0) {
456
+ return check({
457
+ id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
458
+ reason: 'only client/browser sources were scanned; DSC-16 does not scan client code',
459
+ details: [detail('C-16.4', 'NA', 'not-run: no server source outside client/browser paths', [])],
460
+ evidence: [],
461
+ })
462
+ }
463
+ const closure = analyseClosure(sources, entry?.relative ?? null)
464
+ const details = [...closure.notes]
465
+ const dynamic = []
466
+ const subprocess = []
467
+ for (const relative of closure.visited) {
468
+ const parsed = parseModule(closure.byRelative.get(relative))
469
+ if (parsed.error) continue
470
+ walkAst(parsed.ast, (node, ancestors) => {
471
+ if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
472
+ subprocess.push({ file: relative, line: lineOf(node), what: `import ${node.source.value}` })
473
+ return
474
+ }
475
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require') {
476
+ const argument = node.arguments[0]
477
+ if (argument?.type === 'Literal' && typeof argument.value === 'string' && CHILD_PROCESS.test(argument.value)) {
478
+ subprocess.push({ file: relative, line: lineOf(node), what: `require('${argument.value}')` })
479
+ }
480
+ }
481
+ if (node.type === 'ImportExpression' && node.source?.type === 'Literal' && typeof node.source.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
482
+ subprocess.push({ file: relative, line: lineOf(node), what: `import('${node.source.value}')` })
483
+ }
484
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'eval') {
485
+ dynamic.push({ file: relative, line: lineOf(node), what: 'eval()', item: 'C-16.1' })
486
+ return
487
+ }
488
+ if (isFunctionConstructor(node)) {
489
+ if (!functionConstructorExempt(node, ancestors)) dynamic.push({ file: relative, line: lineOf(node), what: 'new Function()', item: 'C-16.1' })
490
+ return
491
+ }
492
+ if (node.type === 'CallExpression' || node.type === 'NewExpression') {
493
+ const names = chainNames(node.callee)
494
+ const joined = names.join('.')
495
+ if (/^vm\.(?:runInContext|runInNewContext|runInThisContext|compileFunction|createContext|Script)$/.test(joined)
496
+ || (names.length === 2 && names[0] === 'vm' && names[1] === 'Script')) {
497
+ dynamic.push({ file: relative, line: lineOf(node), what: joined, item: 'C-16.2' })
498
+ return
499
+ }
500
+ if (names.length === 2 && names[1] === '_compile' && ['module', 'Module'].includes(names[0])) {
501
+ dynamic.push({ file: relative, line: lineOf(node), what: `${names[0]}._compile`, item: 'C-16.2' })
502
+ return
503
+ }
504
+ if (names.join('.') === 'process.binding') dynamic.push({ file: relative, line: lineOf(node), what: 'process.binding', item: 'C-16.2' })
505
+ }
506
+ })
507
+ }
508
+ const dynamicEvidence = dynamic.slice(0, 5).map(item => `${item.file}:${item.line}`)
509
+ const subprocessEvidence = subprocess.slice(0, 5).map(item => `${item.file}:${item.line}`)
510
+ for (const item of dynamic.slice(0, 8)) {
511
+ details.push(detail(item.item, 'FAIL', `dynamic execution (code:dynamic): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
512
+ }
513
+ for (const item of subprocess.slice(0, 8)) {
514
+ details.push(detail('C-16.3', 'WARN', `subprocess capability observed: ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
515
+ }
516
+ if (closure.packageImports.length > 0) {
517
+ details.push(detail('C-16.4', 'NA', `not-run: ${closure.packageImports.length} package import(s) are outside the local closure (for example ${closure.packageImports[0].specifier})`, [`${closure.packageImports[0].file}:${closure.packageImports[0].line}`]))
518
+ }
519
+ for (const error of closure.parseErrors) {
520
+ details.push(detail('C-16.4', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
521
+ }
522
+ const coverage = closure.parseErrors.length > 0 || closure.packageImports.length > 0 || closure.unresolved.length > 0 ? 'partial' : 'full'
523
+ let result
524
+ let reason = ''
525
+ if (dynamic.length > 0) {
526
+ result = 'FAIL'
527
+ reason = `${dynamic.length} dynamic-execution site(s) in the server entry closure (code:dynamic)`
528
+ if (closure.parseErrors.length > 0) reason += `; ${closure.parseErrors.length} file(s) could not be parsed`
529
+ } else if (closure.parseErrors.length > 0) {
530
+ result = 'ERROR'
531
+ reason = `AST parse failed in ${closure.parseErrors.length} closure file(s); DSC-16 not-run`
532
+ } else if (subprocess.length > 0) {
533
+ result = 'WARN'
534
+ reason = `${subprocess.length} subprocess site(s) in the server entry closure`
535
+ } else {
536
+ result = 'PASS'
537
+ }
538
+ if (coverage === 'partial' && result === 'PASS') reason = `partial: ${closure.packageImports.length} package/unresolved import(s) were not scanned`
539
+ return check({
540
+ id: 'DSC-16',
541
+ clause: 'C-16',
542
+ layer: 'L0',
543
+ result,
544
+ coverage: result === 'ERROR' ? 'not-run' : coverage,
545
+ reason,
546
+ details,
547
+ evidence: dynamicEvidence.length > 0 ? dynamicEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
548
+ })
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // DSC-17: sensitive-file reads (AST argument backtracking)
553
+ // ---------------------------------------------------------------------------
554
+
555
+ function classifySecretPath(value) {
556
+ const normalized = String(value).replace(/\\/g, '/').toLowerCase()
557
+ if (/(?:^|\/)id_(?:rsa|ed25519|ecdsa|dsa)(?:[^/]*)?$/.test(normalized) || /(?:^|\/)\.ssh(?:\/|$)/.test(normalized)) return 'fail'
558
+ if (/(?:^|\/)\.aws(?:\/|$)|(?:^|\/)\.npmrc$|(?:^|\/)\.netrc$|(?:^|\/)\.pypi(?:rc)?$|(?:^|\/)\.pypirc$/.test(normalized)) return 'fail'
559
+ if (/(?:^|\/)tokens?(?:[-_.][^/]*)?$/.test(normalized)) return 'fail'
560
+ if (/(?:^|\/)\.dsh(?:\/|$)/.test(normalized) && /credential|auth|token|secret|key/.test(normalized)) return 'fail'
561
+ if (/\$dsh_home|\$home/.test(normalized) && /credential|token|secret/.test(normalized)) return 'fail'
562
+ if (/(?:^|\/)\.env(?:$|\.)/.test(normalized) && !/\.(?:example|sample|template|dist)$/.test(normalized)) return 'warn'
563
+ return null
564
+ }
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
+
711
+ function isEnvEnumeration(node) {
712
+ return node.type === 'CallExpression'
713
+ && node.callee.type === 'MemberExpression'
714
+ && identifierName(node.callee.object) === 'Object'
715
+ && memberPropertyName(node.callee) === 'keys'
716
+ && node.arguments[0]?.type === 'MemberExpression'
717
+ && identifierName(node.arguments[0].object) === 'process'
718
+ && memberPropertyName(node.arguments[0]) === 'env'
719
+ }
720
+
721
+ function hasEgressCall(ast) {
722
+ let found = false
723
+ walkAst(ast, (node) => {
724
+ if (!found && isEgressCall(node)) found = true
725
+ })
726
+ return found
727
+ }
728
+
729
+ export function checkSensitiveFileReads(manifest, sources) {
730
+ if (!sources || sources.length === 0) {
731
+ return check({
732
+ id: 'DSC-17', clause: 'C-17', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
733
+ reason: 'no source files scanned; DSC-17 not-run',
734
+ details: [detail('C-17.1', 'NA', 'not-run: no source files', [])],
735
+ evidence: [],
736
+ })
737
+ }
738
+ const details = []
739
+ const failures = []
740
+ const warnings = []
741
+ let parseErrors = 0
742
+ for (const source of serverSources(sources)) {
743
+ const parsed = parseModule(source)
744
+ if (parsed.error) { parseErrors += 1; continue }
745
+ let envEnumeration = false
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
+ }
760
+ if (isEnvEnumeration(node)) envEnumeration = true
761
+ if (node.type !== 'CallExpression' || node.arguments.length === 0) return
762
+ const api = node.callee.type === 'Identifier' ? node.callee.name : memberPropertyName(node.callee)
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
+ })
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
+ }
816
+ if (envEnumeration && hasEgressCall(parsed.ast)) {
817
+ warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', item: 'C-17.2', reason: 'environment enumeration with egress' })
818
+ }
819
+ }
820
+ for (const item of failures.slice(0, 8)) {
821
+ details.push(detail('C-17.1', 'FAIL', item.reason, [`${item.file}:${item.line}`]))
822
+ }
823
+ for (const item of warnings.slice(0, 8)) {
824
+ details.push(detail(item.item ?? 'C-17.2', 'WARN', item.reason, [`${item.file}:${item.line}`]))
825
+ }
826
+ if (parseErrors > 0) {
827
+ details.push(detail('C-17.3', 'ERROR', `not-run: AST parse failed in ${parseErrors} source file(s)`, []))
828
+ }
829
+ if (failures.length === 0 && warnings.length === 0 && parseErrors === 0) {
830
+ details.push(detail('C-17.1', 'PASS', 'no sensitive-file read argument found in the scanned sources'))
831
+ }
832
+ let result
833
+ if (failures.length > 0) result = 'FAIL'
834
+ else if (parseErrors > 0) result = 'ERROR'
835
+ else if (warnings.length > 0) result = 'WARN'
836
+ else result = 'PASS'
837
+ return check({
838
+ id: 'DSC-17',
839
+ clause: 'C-17',
840
+ layer: 'L0',
841
+ result,
842
+ coverage: parseErrors > 0 ? 'partial' : 'full',
843
+ reason: failures.length > 0
844
+ ? `${failures.length} sensitive-file read(s) in API arguments`
845
+ : parseErrors > 0
846
+ ? `AST parse failed in ${parseErrors} file(s); DSC-17 partial`
847
+ : warnings.length > 0 ? `${warnings.length} dotenv/environment clue(s)` : '',
848
+ details,
849
+ evidence: failures.slice(0, 5).map(item => `${item.file}:${item.line}`),
850
+ })
851
+ }
852
+
853
+ // ---------------------------------------------------------------------------
854
+ // DSC-18: egress endpoints
855
+ // ---------------------------------------------------------------------------
856
+
857
+ function isPrivateIp(host) {
858
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false
859
+ const parts = host.split('.').map(Number)
860
+ if (parts.some(part => part > 255)) return false
861
+ if (parts[0] === 10 || parts[0] === 127) return true
862
+ if (parts[0] === 192 && parts[1] === 168) return true
863
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
864
+ if (parts[0] === 169 && parts[1] === 254) return true
865
+ return false
866
+ }
867
+
868
+ function egressEndpointFrom(node, bindings) {
869
+ if (!node) return null
870
+ if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') return node.arguments[0]
871
+ return node
872
+ }
873
+
874
+ function parseEndpoint(raw) {
875
+ try {
876
+ const url = new URL(raw)
877
+ const pathname = url.pathname.replace(/\/+$/, '') || '/'
878
+ return {
879
+ url,
880
+ normalized: `${url.protocol}//${url.host}${pathname}`,
881
+ host: url.hostname,
882
+ }
883
+ } catch {
884
+ return null
885
+ }
886
+ }
887
+
888
+ export function checkEgressEndpoints(manifest, sources) {
889
+ if (!sources || sources.length === 0) {
890
+ return check({
891
+ id: 'DSC-18', clause: 'C-18', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
892
+ reason: 'no source files scanned; DSC-18 not-run',
893
+ details: [detail('C-18.3', 'NA', 'not-run: no source files', [])],
894
+ evidence: [],
895
+ })
896
+ }
897
+ const endpoints = []
898
+ const dynamic = []
899
+ let parseErrors = 0
900
+ for (const source of serverSources(sources)) {
901
+ const parsed = parseModule(source)
902
+ if (parsed.error) { parseErrors += 1; continue }
903
+ walkAst(parsed.ast, (node) => {
904
+ let target = null
905
+ if (node.type === 'CallExpression') {
906
+ const names = chainNames(node.callee)
907
+ const last = names[names.length - 1]
908
+ if (last === 'fetch' || (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)) || names[0] === 'axios') {
909
+ target = egressEndpointFrom(node.arguments[0], parsed.bindings)
910
+ }
911
+ } else if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') {
912
+ target = egressEndpointFrom(node, parsed.bindings)
913
+ }
914
+ if (!target) return
915
+ const raw = stringFromNode(target, parsed.bindings)
916
+ if (raw === null) {
917
+ dynamic.push({ file: source.relative, line: lineOf(node) })
918
+ return
919
+ }
920
+ const endpoint = parseEndpoint(raw)
921
+ if (!endpoint) return
922
+ endpoints.push({ file: source.relative, line: lineOf(node), ...endpoint, raw })
923
+ })
924
+ }
925
+ const disclosure = manifest?.dsh?.disclosure
926
+ const declared = Array.isArray(disclosure?.endpoints) ? disclosure.endpoints : null
927
+ const declaredNormalized = declared === null ? null : new Set(declared.map(value => {
928
+ const parsed = parseEndpoint(String(value))
929
+ return parsed ? parsed.normalized : String(value)
930
+ }))
931
+ const details = []
932
+ const warnings = []
933
+ for (const endpoint of endpoints) {
934
+ const clues = []
935
+ if (endpoint.url.protocol !== 'https:') clues.push(`non-https scheme ${endpoint.url.protocol}`)
936
+ if (endpoint.url.username || endpoint.url.password) clues.push('userinfo in the URL')
937
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(endpoint.host) && !isPrivateIp(endpoint.host)) clues.push('public bare IP host')
938
+ if (SUSPICIOUS_HOST.test(endpoint.host) || /\.onion$/i.test(endpoint.host)) clues.push('webhook/paste/ngrok/onion host')
939
+ if (/^xn--/i.test(endpoint.host) || endpoint.host.includes('.xn--')) clues.push('punycode host')
940
+ if (declaredNormalized !== null && !declaredNormalized.has(endpoint.normalized)) clues.push('endpoint is not listed in dsh.disclosure.endpoints')
941
+ if (clues.length > 0) warnings.push({ ...endpoint, clues, item: clues.some(clue => clue.startsWith('endpoint is not listed')) ? 'C-18.2' : 'C-18.1' })
942
+ }
943
+ for (const item of warnings.slice(0, 8)) {
944
+ details.push(detail(item.item, 'WARN', `${item.clues.join('; ')} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
945
+ }
946
+ for (const item of dynamic.slice(0, 3)) {
947
+ details.push(detail('C-18.3', 'NA', `not-run: dynamic endpoint argument at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
948
+ }
949
+ if (parseErrors > 0) {
950
+ details.push(detail('C-18.3', 'NA', `not-run: AST parse failed in ${parseErrors} source file(s); endpoint coverage is partial`, []))
951
+ }
952
+ for (const endpoint of endpoints.slice(0, 10)) {
953
+ if (warnings.some(item => item.file === endpoint.file && item.line === endpoint.line)) continue
954
+ details.push(detail('C-18.3', 'PASS', `registered egress endpoint ${endpoint.normalized} at ${endpoint.file}:${endpoint.line}`, [`${endpoint.file}:${endpoint.line}`]))
955
+ }
956
+ if (endpoints.length === 0 && dynamic.length === 0) {
957
+ details.push(detail('C-18.3', 'PASS', 'no fetch/http request call site found'))
958
+ }
959
+ if (warnings.length === 0 && endpoints.length > 0) {
960
+ details.push(detail('C-18.3', 'PASS', `${endpoints.length} egress endpoint(s) registered; no suspicious host or scheme`))
961
+ }
962
+ const result = warnings.length > 0 ? 'WARN' : 'PASS'
963
+ return check({
964
+ id: 'DSC-18',
965
+ clause: 'C-18',
966
+ layer: 'L0',
967
+ result,
968
+ coverage: dynamic.length > 0 || parseErrors > 0 ? 'partial' : 'full',
969
+ advisory: true,
970
+ reason: warnings.length > 0 ? `${warnings.length} egress endpoint clue(s) (advisory)` : '',
971
+ details,
972
+ evidence: warnings.slice(0, 5).map(item => `${item.file}:${item.line}`),
973
+ })
974
+ }
975
+
976
+ // ---------------------------------------------------------------------------
977
+ // DSC-19: opaque text / binary artifacts
978
+ // ---------------------------------------------------------------------------
979
+
980
+ function shannonEntropy(text) {
981
+ const counts = new Map()
982
+ for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1)
983
+ let entropy = 0
984
+ const length = text.length
985
+ for (const count of counts.values()) {
986
+ const probability = count / length
987
+ entropy -= probability * Math.log2(probability)
988
+ }
989
+ return entropy
990
+ }
991
+
992
+ export function checkOpaqueArtifacts(dir, sources) {
993
+ if (!dir) {
994
+ return check({
995
+ id: 'DSC-19', clause: 'C-19', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
996
+ reason: 'no package directory; DSC-19 not-run',
997
+ details: [detail('C-19.1', 'NA', 'not-run: no package directory', [])],
998
+ evidence: [],
999
+ })
1000
+ }
1001
+ const inventory = inventoryFiles(dir)
1002
+ const details = []
1003
+ const binaries = []
1004
+ const opaque = []
1005
+ for (const file of inventory.files) {
1006
+ const extension = path.extname(file.relative).toLowerCase()
1007
+ if (BINARY_EXTENSIONS.has(extension)) {
1008
+ let buffer
1009
+ try { buffer = fs.readFileSync(file.file) } catch { continue }
1010
+ const sha256 = createHash('sha256').update(buffer).digest('hex')
1011
+ binaries.push({ relative: file.relative, size: file.size, sha256 })
1012
+ continue
1013
+ }
1014
+ if (!TEXT_EXTENSIONS.test(extension) || file.size > 512_000) continue
1015
+ let text
1016
+ try { text = fs.readFileSync(file.file, 'utf8') } catch { continue }
1017
+ if (/(?:\/\/[#@]\s*|\/\*[#@]?\s*)sourceMappingURL=/.test(text)) continue
1018
+ const longestLine = text.split(/\r?\n/).reduce((max, line) => Math.max(max, line.length), 0)
1019
+ if (longestLine > 20_000) {
1020
+ opaque.push({ relative: file.relative, clue: `single line of ${longestLine} chars without a sourcemap` })
1021
+ continue
1022
+ }
1023
+ if (/(?:^|[^A-Za-z0-9+/=])[A-Za-z0-9+/]{4000,}={0,2}(?:[^A-Za-z0-9+/=]|$)/.test(text)) {
1024
+ opaque.push({ relative: file.relative, clue: 'base64 block of 4000+ chars' })
1025
+ continue
1026
+ }
1027
+ if (text.length > 4096 && shannonEntropy(text) > 7.5) {
1028
+ opaque.push({ relative: file.relative, clue: `high entropy ${shannonEntropy(text).toFixed(2)} over ${text.length} chars` })
1029
+ }
1030
+ }
1031
+ for (const item of opaque.slice(0, 8)) {
1032
+ details.push(detail('C-19.1', 'WARN', `opaque text clue: ${item.clue} in ${item.relative}`, [item.relative]))
1033
+ }
1034
+ for (const item of binaries.slice(0, 8)) {
1035
+ details.push(detail('C-19.2', 'WARN', `binary artifact ${item.relative} (${item.size} bytes, sha256:${item.sha256}) is recorded, not judged`, [item.relative]))
1036
+ }
1037
+ if (opaque.length === 0 && binaries.length === 0) {
1038
+ details.push(detail('C-19.1', 'PASS', 'no opaque text or binary artifact above the advisory thresholds'))
1039
+ }
1040
+ if (inventory.stats.truncated) {
1041
+ details.push(detail('C-19.1', 'NA', 'not-run: file inventory was truncated; opaque artifacts beyond the budget were not scanned', []))
1042
+ }
1043
+ const result = opaque.length > 0 || binaries.length > 0 ? 'WARN' : 'PASS'
1044
+ return check({
1045
+ id: 'DSC-19',
1046
+ clause: 'C-19',
1047
+ layer: 'L0',
1048
+ result,
1049
+ coverage: inventory.stats.truncated ? 'partial' : 'full',
1050
+ advisory: true,
1051
+ reason: result === 'WARN' ? `${opaque.length} opaque text clue(s), ${binaries.length} binary artifact(s) (advisory)` : '',
1052
+ details,
1053
+ evidence: [...opaque, ...binaries].slice(0, 5).map(item => item.relative),
1054
+ })
1055
+ }
1056
+
1057
+ // ---------------------------------------------------------------------------
1058
+ // DSC-20: prompt-injection clues
1059
+ // ---------------------------------------------------------------------------
1060
+
1061
+ function propertyKeyOf(ancestors) {
1062
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
1063
+ const node = ancestors[index]
1064
+ if (node.type === 'Property') return keyName(node)
1065
+ if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') continue
1066
+ return null
1067
+ }
1068
+ return null
1069
+ }
1070
+
1071
+ function isRuleContext(ancestors, node) {
1072
+ const key = propertyKeyOf(ancestors)
1073
+ if (key && RULE_KEYS.has(key)) return true
1074
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
1075
+ const ancestor = ancestors[index]
1076
+ if (ancestor.type === 'NewExpression' && chainNames(ancestor.callee).join('.') === 'RegExp') return true
1077
+ if (ancestor.type === 'CallExpression' && ancestor.callee.type === 'MemberExpression') {
1078
+ const method = memberPropertyName(ancestor.callee)
1079
+ if (['test', 'match', 'matchAll', 'exec', 'includes', 'compile', 'replace', 'split'].includes(method)) return true
1080
+ }
1081
+ }
1082
+ return false
1083
+ }
1084
+
1085
+ function collectParameterNames(ast) {
1086
+ const names = new Set()
1087
+ walkAst(ast, (node) => {
1088
+ if (!['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type)) return
1089
+ for (const parameter of node.params ?? []) {
1090
+ if (parameter.type === 'Identifier') names.add(parameter.name)
1091
+ if (parameter.type === 'AssignmentPattern' && parameter.left?.type === 'Identifier') names.add(parameter.left.name)
1092
+ }
1093
+ })
1094
+ return names
1095
+ }
1096
+
1097
+ function isExternalExpression(node, bindings, parameters, depth = 0, seen = new Set()) {
1098
+ if (!node || depth > 4 || seen.has(node)) return false
1099
+ if (node.type === 'Identifier') {
1100
+ if (parameters.has(node.name)) return true
1101
+ const init = bindings.get(node.name)
1102
+ if (!init) return false
1103
+ return isExternalExpression(init, bindings, parameters, depth + 1, new Set(seen).add(node))
1104
+ }
1105
+ if (node.type === 'AwaitExpression') return isExternalExpression(node.argument, bindings, parameters, depth + 1, new Set(seen).add(node))
1106
+ if (node.type === 'TemplateLiteral') {
1107
+ return node.expressions.some(expression => isExternalExpression(expression, bindings, parameters, depth + 1, new Set(seen).add(node)))
1108
+ }
1109
+ if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
1110
+ return isExternalExpression(node.left, bindings, parameters, depth + 1, new Set(seen).add(node))
1111
+ || isExternalExpression(node.right, bindings, parameters, depth + 1, new Set(seen).add(node))
1112
+ }
1113
+ if (node.type === 'MemberExpression') {
1114
+ return ['tools', 'toolResult', 'messages', 'results'].includes(memberPropertyName(node))
1115
+ }
1116
+ if (node.type === 'CallExpression') {
1117
+ const names = chainNames(node.callee)
1118
+ const joined = names.join('.').toLowerCase()
1119
+ return /fetch|request|tool|invoke|call|read|query|search|message|completion/.test(joined)
1120
+ }
1121
+ return false
1122
+ }
1123
+
1124
+ export function checkPromptInjectionClues(manifest, sources) {
1125
+ if (!sources || sources.length === 0) {
1126
+ return check({
1127
+ id: 'DSC-20', clause: 'C-20', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
1128
+ reason: 'no source files scanned; DSC-20 not-run',
1129
+ details: [detail('C-20.1', 'NA', 'not-run: no source files', [])],
1130
+ evidence: [],
1131
+ })
1132
+ }
1133
+ const details = []
1134
+ const clues = []
1135
+ for (const source of serverSources(sources)) {
1136
+ if (RULE_FILE.test(source.relative)) continue
1137
+ const parsed = parseModule(source)
1138
+ if (parsed.error) continue
1139
+ const parameters = collectParameterNames(parsed.ast)
1140
+ walkAst(parsed.ast, (node, ancestors) => {
1141
+ if (node.type !== 'CallExpression') return
1142
+ const names = chainNames(node.callee)
1143
+ if (!names.includes('systemPrompt')) return
1144
+ const method = names[names.length - 1]
1145
+ if (!['section', 'context', 'variable'].includes(method)) return
1146
+ const argument = node.arguments[0]
1147
+ if (!argument) return
1148
+ if (isRuleContext(ancestors, node)) return
1149
+ const property = propertyKeyOf(ancestors)
1150
+ if (property && DOC_KEYS.has(property)) return
1151
+ const literal = argument.type === 'TemplateLiteral'
1152
+ ? stringFromNode(argument, parsed.bindings)
1153
+ : literalStringValue(argument)
1154
+ if (literal !== null) {
1155
+ if (INSTRUCTION_PATTERN.test(literal)) {
1156
+ clues.push({ file: source.relative, line: lineOf(node), what: 'instruction-like systemPrompt literal' })
1157
+ }
1158
+ return
1159
+ }
1160
+ if (isExternalExpression(argument, parsed.bindings, parameters)) {
1161
+ clues.push({ file: source.relative, line: lineOf(node), what: 'systemPrompt argument derives from external input' })
1162
+ }
1163
+ })
1164
+ }
1165
+ for (const clue of clues.slice(0, 8)) {
1166
+ details.push(detail('C-20.1', 'WARN', `${clue.what} at ${clue.file}:${clue.line}; this is a clue, not a judgement`, [`${clue.file}:${clue.line}`]))
1167
+ }
1168
+ if (clues.length === 0) details.push(detail('C-20.1', 'PASS', 'no prompt-surface clue found (instruction-like literal or external systemPrompt argument)'))
1169
+ return check({
1170
+ id: 'DSC-20',
1171
+ clause: 'C-20',
1172
+ layer: 'L0',
1173
+ result: clues.length > 0 ? 'WARN' : 'PASS',
1174
+ coverage: 'full',
1175
+ advisory: true,
1176
+ reason: clues.length > 0 ? `${clues.length} prompt-injection clue(s); semantic intent is not judged` : '',
1177
+ details,
1178
+ evidence: clues.slice(0, 5).map(clue => `${clue.file}:${clue.line}`),
1179
+ })
1180
+ }
1181
+
1182
+ // ---------------------------------------------------------------------------
1183
+ // DSC-21: supply-chain name similarity
1184
+ // ---------------------------------------------------------------------------
1185
+
1186
+ const KNOWN_SEEDS = [
1187
+ '@deepseek-ai/dsh', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-app-boot',
1188
+ '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/dsh-credentials',
1189
+ '@deepseek-ai/dsh-credentials-local', '@deepseek-ai/dsh-session',
1190
+ '@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-fs-sandbox',
1191
+ '@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-interaction', '@deepseek-ai/dsh-llm',
1192
+ '@deepseek-ai/dsh-web', '@deepseek-ai/dsh-client', 'cordis',
1193
+ '@cordisjs/core', 'schemastery', 'cosmokit',
1194
+ ]
1195
+ const KNOWN_SET = new Set(KNOWN_SEEDS)
1196
+ const SEED_BASENAMES = [...new Set(KNOWN_SEEDS.map(name => name.split('/').pop()))]
1197
+
1198
+ function editDistanceWithin(left, right, limit) {
1199
+ if (Math.abs(left.length - right.length) > limit) return false
1200
+ const previous = new Array(right.length + 1).fill(0).map((_, index) => index)
1201
+ for (let i = 1; i <= left.length; i += 1) {
1202
+ let diagonal = previous[0]
1203
+ previous[0] = i
1204
+ let rowMin = previous[0]
1205
+ for (let j = 1; j <= right.length; j += 1) {
1206
+ const above = previous[j]
1207
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1
1208
+ previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + cost)
1209
+ diagonal = above
1210
+ rowMin = Math.min(rowMin, previous[j])
1211
+ }
1212
+ if (rowMin > limit) return false
1213
+ }
1214
+ return previous[right.length] <= limit
1215
+ }
1216
+
1217
+ function normalizeHomoglyph(value) {
1218
+ return String(value).toLowerCase().replace(/0/g, 'o').replace(/1/g, 'l').replace(/rn/g, 'm')
1219
+ }
1220
+
1221
+ function scopeOf(name) {
1222
+ return name.startsWith('@') ? name.split('/')[0] : null
1223
+ }
1224
+
1225
+ export function checkSupplyChain(manifest) {
1226
+ const dependencies = {
1227
+ ...(manifest?.dependencies && typeof manifest.dependencies === 'object' ? manifest.dependencies : {}),
1228
+ ...(manifest?.optionalDependencies && typeof manifest.optionalDependencies === 'object' ? manifest.optionalDependencies : {}),
1229
+ ...(manifest?.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {}),
1230
+ }
1231
+ const names = Object.keys(dependencies)
1232
+ const details = []
1233
+ const findings = []
1234
+ let knownCount = 0
1235
+ for (const name of names) {
1236
+ if (KNOWN_SET.has(name)) { knownCount += 1; continue }
1237
+ const scope = scopeOf(name)
1238
+ if (scope && scope !== '@deepseek-ai' && (editDistanceWithin(scope, '@deepseek-ai', 1) || normalizeHomoglyph(scope) === normalizeHomoglyph('@deepseek-ai'))) {
1239
+ findings.push({ name, item: 'C-21.2', why: `dependency scope ${scope} is a near-miss of the official @deepseek-ai scope` })
1240
+ continue
1241
+ }
1242
+ const basename = name.split('/').pop()
1243
+ if (!/dsh/i.test(basename) && !/dsh/i.test(scope ?? '')) continue
1244
+ const similar = SEED_BASENAMES.find(seed => seed !== basename && editDistanceWithin(basename, seed, 1))
1245
+ if (similar) findings.push({ name, item: 'C-21.1', why: `dependency ${name} is one edit away from known package ${similar}` })
1246
+ }
1247
+ for (const finding of findings.slice(0, 8)) {
1248
+ details.push(detail(finding.item, 'WARN', finding.why, [`package.json#dependencies.${finding.name}`]))
1249
+ }
1250
+ if (names.length === 0) {
1251
+ details.push(detail('C-21.1', 'PASS', 'no runtime dependencies declared'))
1252
+ return check({
1253
+ id: 'DSC-21', clause: 'C-21', layer: 'L0', result: 'PASS', coverage: 'full', advisory: false,
1254
+ reason: '', details, evidence: ['package.json#dependencies'],
1255
+ })
1256
+ }
1257
+ details.push(detail('C-21.3', 'NA', 'not-run: dependency install scripts and transitive registry metadata need registry data; a missing lockfile is not treated as a defect', []))
1258
+ if (findings.length === 0) {
1259
+ details.push(detail('C-21.1', 'PASS', `${names.length} dependency name(s) checked; ${knownCount} exact known name(s)`))
1260
+ }
1261
+ return check({
1262
+ id: 'DSC-21',
1263
+ clause: 'C-21',
1264
+ layer: 'L0',
1265
+ result: findings.length > 0 ? 'WARN' : 'PASS',
1266
+ coverage: 'partial',
1267
+ advisory: false,
1268
+ reason: findings.length > 0 ? `${findings.length} supply-chain name clue(s)` : '',
1269
+ details,
1270
+ evidence: findings.slice(0, 5).map(finding => `package.json#dependencies.${finding.name}`),
1271
+ })
1272
+ }
1273
+
1274
+ // ---------------------------------------------------------------------------
1275
+ // DSC-22: provenance / integrity (advisory only)
1276
+ // ---------------------------------------------------------------------------
1277
+
1278
+ export function checkProvenance(manifest) {
1279
+ const integrity = manifest?.dist?.integrity
1280
+ const attestation = manifest?.dsh?.attestation
1281
+ const signature = manifest?.dsh?.signature
1282
+ const details = []
1283
+ if (typeof integrity === 'string' && integrity.trim() !== '') {
1284
+ details.push(detail('C-22.1', 'PASS', 'dist.integrity is present; this is integrity-only and does not prove provenance', ['package.json#dist.integrity']))
1285
+ return check({
1286
+ id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
1287
+ reason: '', details, evidence: ['package.json#dist.integrity'],
1288
+ })
1289
+ }
1290
+ if ((typeof attestation === 'object' && attestation !== null) || (typeof signature === 'object' && signature !== null) || typeof attestation === 'string' || typeof signature === 'string') {
1291
+ details.push(detail('C-22.1', 'PASS', 'attestation/signature field is present (self-declared; not verified by this checker)', ['package.json#dsh.attestation', 'package.json#dsh.signature']))
1292
+ return check({
1293
+ id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
1294
+ reason: '', details, evidence: ['package.json#dsh.attestation'],
1295
+ })
1296
+ }
1297
+ details.push(detail('C-22.1', 'NA', 'not-run: no registry metadata or attestation supplied; provenance is not judged', []))
1298
+ return check({
1299
+ id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
1300
+ reason: 'no registry metadata/attestation input; not-run',
1301
+ details, evidence: [],
1302
+ })
1303
+ }
1304
+
1305
+ // ---------------------------------------------------------------------------
1306
+ // aggregate
1307
+ // ---------------------------------------------------------------------------
1308
+
1309
+ function unavailable(reason) {
1310
+ return SECURITY_IDS.map(id => check({
1311
+ id,
1312
+ clause: SECURITY_CLAUSE[id],
1313
+ layer: 'L0',
1314
+ result: 'ERROR',
1315
+ coverage: 'not-run',
1316
+ advisory: SECURITY_ADVISORY.has(id),
1317
+ reason,
1318
+ details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${reason}`, [])],
1319
+ evidence: [],
1320
+ }))
1321
+ }
1322
+
1323
+ /** Run all eight security checks. Never throws: a failure becomes not-run rows. */
1324
+ export function runSecurityChecks({ dir, manifest, sources = [], entry = null, kind = null } = {}) {
1325
+ if (!manifest) return unavailable('manifest unavailable')
1326
+ const checks = []
1327
+ const run = (id, factory) => {
1328
+ try {
1329
+ checks.push(factory())
1330
+ } catch (error) {
1331
+ checks.push(check({
1332
+ id,
1333
+ clause: SECURITY_CLAUSE[id],
1334
+ layer: 'L0',
1335
+ result: 'ERROR',
1336
+ coverage: 'not-run',
1337
+ advisory: SECURITY_ADVISORY.has(id),
1338
+ reason: `checker error: ${error?.message ?? error}`,
1339
+ details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${error?.message ?? error}`, [])],
1340
+ evidence: [],
1341
+ }))
1342
+ }
1343
+ }
1344
+ run('DSC-15', () => checkLifecycleScripts(manifest, dir))
1345
+ run('DSC-16', () => checkDynamicExecution(manifest, sources, entry, kind))
1346
+ run('DSC-17', () => checkSensitiveFileReads(manifest, sources))
1347
+ run('DSC-18', () => checkEgressEndpoints(manifest, sources))
1348
+ run('DSC-19', () => checkOpaqueArtifacts(dir, sources))
1349
+ run('DSC-20', () => checkPromptInjectionClues(manifest, sources))
1350
+ run('DSC-21', () => checkSupplyChain(manifest))
1351
+ run('DSC-22', () => checkProvenance(manifest))
1352
+ return checks
1353
+ }