dsh-standards 1.5.0 → 1.6.0

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 (63) 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-missing-ref/index.mjs +3 -0
  4. package/40-checker/fixtures/security/sec15-missing-ref/package.json +22 -0
  5. package/40-checker/fixtures/security/sec15-node/index.mjs +3 -0
  6. package/40-checker/fixtures/security/sec15-node/package.json +22 -0
  7. package/40-checker/fixtures/security/sec15-node/scripts/build.mjs +1 -0
  8. package/40-checker/fixtures/security/sec15-pipe/index.mjs +3 -0
  9. package/40-checker/fixtures/security/sec15-pipe/package.json +22 -0
  10. package/40-checker/fixtures/security/sec16-client/index.mjs +3 -0
  11. package/40-checker/fixtures/security/sec16-client/lib/client.js +3 -0
  12. package/40-checker/fixtures/security/sec16-client/package.json +21 -0
  13. package/40-checker/fixtures/security/sec16-declared/index.mjs +3 -0
  14. package/40-checker/fixtures/security/sec16-declared/package.json +19 -0
  15. package/40-checker/fixtures/security/sec16-eval/index.mjs +3 -0
  16. package/40-checker/fixtures/security/sec16-eval/package.json +19 -0
  17. package/40-checker/fixtures/security/sec16-require/index.mjs +4 -0
  18. package/40-checker/fixtures/security/sec16-require/package.json +19 -0
  19. package/40-checker/fixtures/security/sec17-env/index.mjs +5 -0
  20. package/40-checker/fixtures/security/sec17-env/package.json +19 -0
  21. package/40-checker/fixtures/security/sec17-seam/index.mjs +3 -0
  22. package/40-checker/fixtures/security/sec17-seam/package.json +19 -0
  23. package/40-checker/fixtures/security/sec17-ssh/index.mjs +6 -0
  24. package/40-checker/fixtures/security/sec17-ssh/package.json +19 -0
  25. package/40-checker/fixtures/security/sec18-bad/index.mjs +3 -0
  26. package/40-checker/fixtures/security/sec18-bad/package.json +21 -0
  27. package/40-checker/fixtures/security/sec18-good/index.mjs +3 -0
  28. package/40-checker/fixtures/security/sec18-good/package.json +26 -0
  29. package/40-checker/fixtures/security/sec18-undeclared/index.mjs +3 -0
  30. package/40-checker/fixtures/security/sec18-undeclared/package.json +26 -0
  31. package/40-checker/fixtures/security/sec19-blob/index.mjs +2 -0
  32. package/40-checker/fixtures/security/sec19-blob/package.json +19 -0
  33. package/40-checker/fixtures/security/sec19-clean/index.mjs +2 -0
  34. package/40-checker/fixtures/security/sec19-clean/package.json +19 -0
  35. package/40-checker/fixtures/security/sec19-wasm/blob.wasm +0 -0
  36. package/40-checker/fixtures/security/sec19-wasm/index.mjs +3 -0
  37. package/40-checker/fixtures/security/sec19-wasm/package.json +19 -0
  38. package/40-checker/fixtures/security/sec20-unwrapped/index.mjs +4 -0
  39. package/40-checker/fixtures/security/sec20-unwrapped/package.json +23 -0
  40. package/40-checker/fixtures/security/sec20-wrapped/index.mjs +3 -0
  41. package/40-checker/fixtures/security/sec20-wrapped/package.json +23 -0
  42. package/40-checker/fixtures/security/sec21-known/index.mjs +3 -0
  43. package/40-checker/fixtures/security/sec21-known/package.json +23 -0
  44. package/40-checker/fixtures/security/sec21-typosquat/index.mjs +3 -0
  45. package/40-checker/fixtures/security/sec21-typosquat/package.json +22 -0
  46. package/40-checker/fixtures/security/sec22-attested/index.mjs +3 -0
  47. package/40-checker/fixtures/security/sec22-attested/package.json +22 -0
  48. package/40-checker/fixtures/security/sec22-none/index.mjs +3 -0
  49. package/40-checker/fixtures/security/sec22-none/package.json +19 -0
  50. package/40-checker/src/cli.mjs +8 -3
  51. package/40-checker/src/declarations.mjs +10 -10
  52. package/40-checker/src/index.mjs +2 -1
  53. package/40-checker/src/report.mjs +68 -2
  54. package/40-checker/src/security.mjs +1065 -0
  55. package/40-checker/src/util.mjs +53 -0
  56. package/CHANGELOG.md +14 -0
  57. package/docs/SIGNING.md +137 -0
  58. package/docs/coverage.md +39 -6
  59. package/package.json +1 -1
  60. package/spec/coverage-contract.md +33 -11
  61. package/spec/dsc-clauses.json +410 -9
  62. package/spec/remediation.json +196 -0
  63. package/spec/schemas/conformance.schema.json +75 -2
@@ -0,0 +1,1065 @@
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
+ function scriptReferenceProblems(name, command, dir, scripts) {
262
+ const problems = []
263
+ const missing = []
264
+ const checkRef = (reference) => {
265
+ if (!reference || reference.startsWith('-')) return
266
+ if (!/^(?:\.{0,2}\/|[\w@.-]+\/)/.test(reference) && !/\.(?:[cm]?[jt]s|sh|ps1)$/.test(reference)) return
267
+ const absolute = path.resolve(dir, reference)
268
+ if (!fs.existsSync(absolute)) missing.push(reference)
269
+ }
270
+ for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)([^\s;&|]+)/g)) checkRef(match[1])
271
+ for (const match of command.matchAll(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:.@-]+)/g)) {
272
+ if (!Object.hasOwn(scripts, match[1])) missing.push(`npm run ${match[1]}`)
273
+ }
274
+ if (missing.length > 0) problems.push(`scripts.${name} references missing script(s): ${[...new Set(missing)].join(', ')}`)
275
+ return problems
276
+ }
277
+
278
+ export function checkLifecycleScripts(manifest, dir) {
279
+ const scripts = manifest?.scripts && typeof manifest.scripts === 'object' && !Array.isArray(manifest.scripts) ? manifest.scripts : {}
280
+ const details = []
281
+ const findings = []
282
+ let present = 0
283
+ for (const name of LIFECYCLE_NAMES) {
284
+ const command = scripts[name]
285
+ if (typeof command !== 'string' || command.trim() === '') continue
286
+ present += 1
287
+ const evidence = [`package.json#scripts.${name}`]
288
+ const dangerous = DANGEROUS_SCRIPT.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
289
+ const missing = scriptReferenceProblems(name, command, dir, scripts)
290
+ if (name === 'prepare' && dangerous.length > 0) {
291
+ 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
+ findings.push('warn')
293
+ } else if (dangerous.length > 0) {
294
+ details.push(detail('C-15.1', 'FAIL', `scripts.${name} uses a dangerous form: ${dangerous.join('; ')}`, evidence))
295
+ findings.push('fail')
296
+ } else if (missing.length > 0) {
297
+ // `prepare` also runs for git/source installs, which pnpm gates behind
298
+ // allowBuilds; a missing prepare target is therefore WARN, not FAIL.
299
+ const level = name === 'prepare' ? 'WARN' : 'FAIL'
300
+ details.push(detail('C-15.3', level, `${missing.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
301
+ findings.push(level === 'WARN' ? 'warn' : 'fail')
302
+ } else {
303
+ details.push(detail('C-15.2', 'WARN', `lifecycle script scripts.${name} is present; install-time commands are not executed by the checker`, evidence))
304
+ findings.push('warn')
305
+ }
306
+ }
307
+ if (present === 0) {
308
+ details.push(detail('C-15.2', 'PASS', 'no preinstall/install/postinstall/prepare script is declared'))
309
+ }
310
+ const result = worst(findings.map(value => value === 'fail' ? 'FAIL' : 'WARN'))
311
+ return check({
312
+ id: 'DSC-15',
313
+ clause: 'C-15',
314
+ layer: 'L0',
315
+ result: findings.length === 0 ? 'PASS' : result,
316
+ coverage: 'full',
317
+ reason: details.filter(item => item.result === 'FAIL' || item.result === 'WARN').map(item => `${item.item}: ${item.reason}`).join(' | '),
318
+ details,
319
+ evidence: ['package.json#scripts'],
320
+ })
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // DSC-16: dynamic execution in the server entry closure
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function allowsEvalContext(ancestors) {
328
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
329
+ const node = ancestors[index]
330
+ if (node.type === 'Property' && keyName(node) === 'allowsEval') return true
331
+ if (node.type === 'VariableDeclarator' && identifierName(node.id) === 'allowsEval') return true
332
+ if (node.type === 'AssignmentExpression' && memberPropertyName(node.left) === 'allowsEval') return true
333
+ }
334
+ return false
335
+ }
336
+
337
+ function isFunctionConstructor(node) {
338
+ if (node.type !== 'NewExpression' && node.type !== 'CallExpression') return false
339
+ return chainNames(node.callee).join('.') === 'Function'
340
+ }
341
+
342
+ function functionConstructorExempt(node, ancestors) {
343
+ const args = node.arguments ?? []
344
+ if (args.length === 0) return true
345
+ if (args.every(arg => arg.type === 'Literal' && arg.value === '')) return true
346
+ return allowsEvalContext(ancestors)
347
+ }
348
+
349
+ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
350
+ if (kind === 'client') {
351
+ return check({
352
+ id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
353
+ reason: 'client-only package; server entry closure is not applicable',
354
+ details: [detail('C-16.4', 'NA', 'not-run: client package has no server entry', ['package.json#dsh.kind'])],
355
+ evidence: ['package.json#dsh.kind'],
356
+ })
357
+ }
358
+ if (!sources || sources.length === 0) {
359
+ return check({
360
+ id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
361
+ reason: 'no source files scanned; DSC-16 not-run',
362
+ details: [detail('C-16.4', 'NA', 'not-run: no source files', [])],
363
+ evidence: [],
364
+ })
365
+ }
366
+ if (serverSources(sources).length === 0) {
367
+ return check({
368
+ id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
369
+ reason: 'only client/browser sources were scanned; DSC-16 does not scan client code',
370
+ details: [detail('C-16.4', 'NA', 'not-run: no server source outside client/browser paths', [])],
371
+ evidence: [],
372
+ })
373
+ }
374
+ const closure = analyseClosure(sources, entry?.relative ?? null)
375
+ const details = [...closure.notes]
376
+ const dynamic = []
377
+ const subprocess = []
378
+ for (const relative of closure.visited) {
379
+ const parsed = parseModule(closure.byRelative.get(relative))
380
+ if (parsed.error) continue
381
+ walkAst(parsed.ast, (node, ancestors) => {
382
+ if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
383
+ subprocess.push({ file: relative, line: lineOf(node), what: `import ${node.source.value}` })
384
+ return
385
+ }
386
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require') {
387
+ const argument = node.arguments[0]
388
+ if (argument?.type === 'Literal' && typeof argument.value === 'string' && CHILD_PROCESS.test(argument.value)) {
389
+ subprocess.push({ file: relative, line: lineOf(node), what: `require('${argument.value}')` })
390
+ }
391
+ }
392
+ if (node.type === 'ImportExpression' && node.source?.type === 'Literal' && typeof node.source.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
393
+ subprocess.push({ file: relative, line: lineOf(node), what: `import('${node.source.value}')` })
394
+ }
395
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'eval') {
396
+ dynamic.push({ file: relative, line: lineOf(node), what: 'eval()', item: 'C-16.1' })
397
+ return
398
+ }
399
+ if (isFunctionConstructor(node)) {
400
+ if (!functionConstructorExempt(node, ancestors)) dynamic.push({ file: relative, line: lineOf(node), what: 'new Function()', item: 'C-16.1' })
401
+ return
402
+ }
403
+ if (node.type === 'CallExpression' || node.type === 'NewExpression') {
404
+ const names = chainNames(node.callee)
405
+ const joined = names.join('.')
406
+ if (/^vm\.(?:runInContext|runInNewContext|runInThisContext|compileFunction|createContext|Script)$/.test(joined)
407
+ || (names.length === 2 && names[0] === 'vm' && names[1] === 'Script')) {
408
+ dynamic.push({ file: relative, line: lineOf(node), what: joined, item: 'C-16.2' })
409
+ return
410
+ }
411
+ if (names.length === 2 && names[1] === '_compile' && ['module', 'Module'].includes(names[0])) {
412
+ dynamic.push({ file: relative, line: lineOf(node), what: `${names[0]}._compile`, item: 'C-16.2' })
413
+ return
414
+ }
415
+ if (names.join('.') === 'process.binding') dynamic.push({ file: relative, line: lineOf(node), what: 'process.binding', item: 'C-16.2' })
416
+ }
417
+ })
418
+ }
419
+ const dynamicEvidence = dynamic.slice(0, 5).map(item => `${item.file}:${item.line}`)
420
+ const subprocessEvidence = subprocess.slice(0, 5).map(item => `${item.file}:${item.line}`)
421
+ for (const item of dynamic.slice(0, 8)) {
422
+ details.push(detail(item.item, 'FAIL', `dynamic execution (code:dynamic): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
423
+ }
424
+ for (const item of subprocess.slice(0, 8)) {
425
+ details.push(detail('C-16.3', 'WARN', `subprocess capability observed: ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
426
+ }
427
+ if (closure.packageImports.length > 0) {
428
+ 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}`]))
429
+ }
430
+ for (const error of closure.parseErrors) {
431
+ details.push(detail('C-16.4', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
432
+ }
433
+ const coverage = closure.parseErrors.length > 0 || closure.packageImports.length > 0 || closure.unresolved.length > 0 ? 'partial' : 'full'
434
+ let result
435
+ let reason = ''
436
+ if (dynamic.length > 0) {
437
+ result = 'FAIL'
438
+ reason = `${dynamic.length} dynamic-execution site(s) in the server entry closure (code:dynamic)`
439
+ if (closure.parseErrors.length > 0) reason += `; ${closure.parseErrors.length} file(s) could not be parsed`
440
+ } else if (closure.parseErrors.length > 0) {
441
+ result = 'ERROR'
442
+ reason = `AST parse failed in ${closure.parseErrors.length} closure file(s); DSC-16 not-run`
443
+ } else if (subprocess.length > 0) {
444
+ result = 'WARN'
445
+ reason = `${subprocess.length} subprocess site(s) in the server entry closure`
446
+ } else {
447
+ result = 'PASS'
448
+ }
449
+ if (coverage === 'partial' && result === 'PASS') reason = `partial: ${closure.packageImports.length} package/unresolved import(s) were not scanned`
450
+ return check({
451
+ id: 'DSC-16',
452
+ clause: 'C-16',
453
+ layer: 'L0',
454
+ result,
455
+ coverage: result === 'ERROR' ? 'not-run' : coverage,
456
+ reason,
457
+ details,
458
+ evidence: dynamicEvidence.length > 0 ? dynamicEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
459
+ })
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // DSC-17: sensitive-file reads (AST argument backtracking)
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function classifySecretPath(value) {
467
+ const normalized = String(value).replace(/\\/g, '/').toLowerCase()
468
+ if (/(?:^|\/)id_(?:rsa|ed25519|ecdsa|dsa)(?:[^/]*)?$/.test(normalized) || /(?:^|\/)\.ssh(?:\/|$)/.test(normalized)) return 'fail'
469
+ if (/(?:^|\/)\.aws(?:\/|$)|(?:^|\/)\.npmrc$|(?:^|\/)\.netrc$|(?:^|\/)\.pypi(?:rc)?$|(?:^|\/)\.pypirc$/.test(normalized)) return 'fail'
470
+ if (/(?:^|\/)tokens?(?:[-_.][^/]*)?$/.test(normalized)) return 'fail'
471
+ if (/(?:^|\/)\.dsh(?:\/|$)/.test(normalized) && /credential|auth|token|secret|key/.test(normalized)) return 'fail'
472
+ if (/\$dsh_home|\$home/.test(normalized) && /credential|token|secret/.test(normalized)) return 'fail'
473
+ if (/(?:^|\/)\.env(?:$|\.)/.test(normalized) && !/\.(?:example|sample|template|dist)$/.test(normalized)) return 'warn'
474
+ return null
475
+ }
476
+
477
+ function isEnvEnumeration(node) {
478
+ return node.type === 'CallExpression'
479
+ && node.callee.type === 'MemberExpression'
480
+ && identifierName(node.callee.object) === 'Object'
481
+ && memberPropertyName(node.callee) === 'keys'
482
+ && node.arguments[0]?.type === 'MemberExpression'
483
+ && identifierName(node.arguments[0].object) === 'process'
484
+ && memberPropertyName(node.arguments[0]) === 'env'
485
+ }
486
+
487
+ function hasEgressCall(ast) {
488
+ let found = false
489
+ walkAst(ast, (node) => {
490
+ if (found || node.type !== 'CallExpression') return
491
+ const names = chainNames(node.callee)
492
+ if (names[names.length - 1] === 'fetch') found = true
493
+ if (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(names[names.length - 1])) found = true
494
+ })
495
+ return found
496
+ }
497
+
498
+ export function checkSensitiveFileReads(manifest, sources) {
499
+ if (!sources || sources.length === 0) {
500
+ return check({
501
+ id: 'DSC-17', clause: 'C-17', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
502
+ reason: 'no source files scanned; DSC-17 not-run',
503
+ details: [detail('C-17.1', 'NA', 'not-run: no source files', [])],
504
+ evidence: [],
505
+ })
506
+ }
507
+ const details = []
508
+ const failures = []
509
+ const warnings = []
510
+ let parseErrors = 0
511
+ for (const source of serverSources(sources)) {
512
+ const parsed = parseModule(source)
513
+ if (parsed.error) { parseErrors += 1; continue }
514
+ let envEnumeration = false
515
+ walkAst(parsed.ast, (node) => {
516
+ if (node.type !== 'CallExpression') return
517
+ if (isEnvEnumeration(node)) envEnumeration = true
518
+ const api = node.callee.type === 'Identifier' ? node.callee.name : memberPropertyName(node.callee)
519
+ if (!READ_APIS.has(api) || node.arguments.length === 0) return
520
+ const value = stringFromNode(node.arguments[0], parsed.bindings)
521
+ if (value === null || value === '') return
522
+ const classification = classifySecretPath(value)
523
+ if (classification === null) return
524
+ const record = { file: source.relative, line: lineOf(node), api, value }
525
+ if (classification === 'fail') failures.push(record)
526
+ else warnings.push(record)
527
+ })
528
+ if (envEnumeration && hasEgressCall(parsed.ast)) {
529
+ warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', value: 'environment enumeration with egress' })
530
+ }
531
+ }
532
+ for (const item of failures.slice(0, 8)) {
533
+ details.push(detail('C-17.1', 'FAIL', `${item.api} reads a sensitive path at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
534
+ }
535
+ for (const item of warnings.slice(0, 8)) {
536
+ details.push(detail('C-17.2', 'WARN', `${item.api} reads a dotenv/environment surface at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
537
+ }
538
+ if (parseErrors > 0) {
539
+ details.push(detail('C-17.3', 'ERROR', `not-run: AST parse failed in ${parseErrors} source file(s)`, []))
540
+ }
541
+ if (failures.length === 0 && warnings.length === 0 && parseErrors === 0) {
542
+ details.push(detail('C-17.1', 'PASS', 'no sensitive-file read argument found in the scanned sources'))
543
+ }
544
+ let result
545
+ if (failures.length > 0) result = 'FAIL'
546
+ else if (parseErrors > 0) result = 'ERROR'
547
+ else if (warnings.length > 0) result = 'WARN'
548
+ else result = 'PASS'
549
+ return check({
550
+ id: 'DSC-17',
551
+ clause: 'C-17',
552
+ layer: 'L0',
553
+ result,
554
+ coverage: parseErrors > 0 ? 'partial' : 'full',
555
+ reason: failures.length > 0
556
+ ? `${failures.length} sensitive-file read(s) in API arguments`
557
+ : parseErrors > 0
558
+ ? `AST parse failed in ${parseErrors} file(s); DSC-17 partial`
559
+ : warnings.length > 0 ? `${warnings.length} dotenv/environment clue(s)` : '',
560
+ details,
561
+ evidence: failures.slice(0, 5).map(item => `${item.file}:${item.line}`),
562
+ })
563
+ }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // DSC-18: egress endpoints
567
+ // ---------------------------------------------------------------------------
568
+
569
+ function isPrivateIp(host) {
570
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false
571
+ const parts = host.split('.').map(Number)
572
+ if (parts.some(part => part > 255)) return false
573
+ if (parts[0] === 10 || parts[0] === 127) return true
574
+ if (parts[0] === 192 && parts[1] === 168) return true
575
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
576
+ if (parts[0] === 169 && parts[1] === 254) return true
577
+ return false
578
+ }
579
+
580
+ function egressEndpointFrom(node, bindings) {
581
+ if (!node) return null
582
+ if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') return node.arguments[0]
583
+ return node
584
+ }
585
+
586
+ function parseEndpoint(raw) {
587
+ try {
588
+ const url = new URL(raw)
589
+ const pathname = url.pathname.replace(/\/+$/, '') || '/'
590
+ return {
591
+ url,
592
+ normalized: `${url.protocol}//${url.host}${pathname}`,
593
+ host: url.hostname,
594
+ }
595
+ } catch {
596
+ return null
597
+ }
598
+ }
599
+
600
+ export function checkEgressEndpoints(manifest, sources) {
601
+ if (!sources || sources.length === 0) {
602
+ return check({
603
+ id: 'DSC-18', clause: 'C-18', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
604
+ reason: 'no source files scanned; DSC-18 not-run',
605
+ details: [detail('C-18.3', 'NA', 'not-run: no source files', [])],
606
+ evidence: [],
607
+ })
608
+ }
609
+ const endpoints = []
610
+ const dynamic = []
611
+ let parseErrors = 0
612
+ for (const source of serverSources(sources)) {
613
+ const parsed = parseModule(source)
614
+ if (parsed.error) { parseErrors += 1; continue }
615
+ walkAst(parsed.ast, (node) => {
616
+ let target = null
617
+ if (node.type === 'CallExpression') {
618
+ const names = chainNames(node.callee)
619
+ const last = names[names.length - 1]
620
+ if (last === 'fetch' || (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)) || names[0] === 'axios') {
621
+ target = egressEndpointFrom(node.arguments[0], parsed.bindings)
622
+ }
623
+ } else if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') {
624
+ target = egressEndpointFrom(node, parsed.bindings)
625
+ }
626
+ if (!target) return
627
+ const raw = stringFromNode(target, parsed.bindings)
628
+ if (raw === null) {
629
+ dynamic.push({ file: source.relative, line: lineOf(node) })
630
+ return
631
+ }
632
+ const endpoint = parseEndpoint(raw)
633
+ if (!endpoint) return
634
+ endpoints.push({ file: source.relative, line: lineOf(node), ...endpoint, raw })
635
+ })
636
+ }
637
+ const disclosure = manifest?.dsh?.disclosure
638
+ const declared = Array.isArray(disclosure?.endpoints) ? disclosure.endpoints : null
639
+ const declaredNormalized = declared === null ? null : new Set(declared.map(value => {
640
+ const parsed = parseEndpoint(String(value))
641
+ return parsed ? parsed.normalized : String(value)
642
+ }))
643
+ const details = []
644
+ const warnings = []
645
+ for (const endpoint of endpoints) {
646
+ const clues = []
647
+ if (endpoint.url.protocol !== 'https:') clues.push(`non-https scheme ${endpoint.url.protocol}`)
648
+ if (endpoint.url.username || endpoint.url.password) clues.push('userinfo in the URL')
649
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(endpoint.host) && !isPrivateIp(endpoint.host)) clues.push('public bare IP host')
650
+ if (SUSPICIOUS_HOST.test(endpoint.host) || /\.onion$/i.test(endpoint.host)) clues.push('webhook/paste/ngrok/onion host')
651
+ if (/^xn--/i.test(endpoint.host) || endpoint.host.includes('.xn--')) clues.push('punycode host')
652
+ if (declaredNormalized !== null && !declaredNormalized.has(endpoint.normalized)) clues.push('endpoint is not listed in dsh.disclosure.endpoints')
653
+ if (clues.length > 0) warnings.push({ ...endpoint, clues, item: clues.some(clue => clue.startsWith('endpoint is not listed')) ? 'C-18.2' : 'C-18.1' })
654
+ }
655
+ for (const item of warnings.slice(0, 8)) {
656
+ details.push(detail(item.item, 'WARN', `${item.clues.join('; ')} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
657
+ }
658
+ for (const item of dynamic.slice(0, 3)) {
659
+ details.push(detail('C-18.3', 'NA', `not-run: dynamic endpoint argument at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
660
+ }
661
+ if (parseErrors > 0) {
662
+ details.push(detail('C-18.3', 'NA', `not-run: AST parse failed in ${parseErrors} source file(s); endpoint coverage is partial`, []))
663
+ }
664
+ for (const endpoint of endpoints.slice(0, 10)) {
665
+ if (warnings.some(item => item.file === endpoint.file && item.line === endpoint.line)) continue
666
+ details.push(detail('C-18.3', 'PASS', `registered egress endpoint ${endpoint.normalized} at ${endpoint.file}:${endpoint.line}`, [`${endpoint.file}:${endpoint.line}`]))
667
+ }
668
+ if (endpoints.length === 0 && dynamic.length === 0) {
669
+ details.push(detail('C-18.3', 'PASS', 'no fetch/http request call site found'))
670
+ }
671
+ if (warnings.length === 0 && endpoints.length > 0) {
672
+ details.push(detail('C-18.3', 'PASS', `${endpoints.length} egress endpoint(s) registered; no suspicious host or scheme`))
673
+ }
674
+ const result = warnings.length > 0 ? 'WARN' : 'PASS'
675
+ return check({
676
+ id: 'DSC-18',
677
+ clause: 'C-18',
678
+ layer: 'L0',
679
+ result,
680
+ coverage: dynamic.length > 0 || parseErrors > 0 ? 'partial' : 'full',
681
+ advisory: true,
682
+ reason: warnings.length > 0 ? `${warnings.length} egress endpoint clue(s) (advisory)` : '',
683
+ details,
684
+ evidence: warnings.slice(0, 5).map(item => `${item.file}:${item.line}`),
685
+ })
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // DSC-19: opaque text / binary artifacts
690
+ // ---------------------------------------------------------------------------
691
+
692
+ function shannonEntropy(text) {
693
+ const counts = new Map()
694
+ for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1)
695
+ let entropy = 0
696
+ const length = text.length
697
+ for (const count of counts.values()) {
698
+ const probability = count / length
699
+ entropy -= probability * Math.log2(probability)
700
+ }
701
+ return entropy
702
+ }
703
+
704
+ export function checkOpaqueArtifacts(dir, sources) {
705
+ if (!dir) {
706
+ return check({
707
+ id: 'DSC-19', clause: 'C-19', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
708
+ reason: 'no package directory; DSC-19 not-run',
709
+ details: [detail('C-19.1', 'NA', 'not-run: no package directory', [])],
710
+ evidence: [],
711
+ })
712
+ }
713
+ const inventory = inventoryFiles(dir)
714
+ const details = []
715
+ const binaries = []
716
+ const opaque = []
717
+ for (const file of inventory.files) {
718
+ const extension = path.extname(file.relative).toLowerCase()
719
+ if (BINARY_EXTENSIONS.has(extension)) {
720
+ let buffer
721
+ try { buffer = fs.readFileSync(file.file) } catch { continue }
722
+ const sha256 = createHash('sha256').update(buffer).digest('hex')
723
+ binaries.push({ relative: file.relative, size: file.size, sha256 })
724
+ continue
725
+ }
726
+ if (!TEXT_EXTENSIONS.test(extension) || file.size > 512_000) continue
727
+ let text
728
+ try { text = fs.readFileSync(file.file, 'utf8') } catch { continue }
729
+ if (/(?:\/\/[#@]\s*|\/\*[#@]?\s*)sourceMappingURL=/.test(text)) continue
730
+ const longestLine = text.split(/\r?\n/).reduce((max, line) => Math.max(max, line.length), 0)
731
+ if (longestLine > 20_000) {
732
+ opaque.push({ relative: file.relative, clue: `single line of ${longestLine} chars without a sourcemap` })
733
+ continue
734
+ }
735
+ if (/(?:^|[^A-Za-z0-9+/=])[A-Za-z0-9+/]{4000,}={0,2}(?:[^A-Za-z0-9+/=]|$)/.test(text)) {
736
+ opaque.push({ relative: file.relative, clue: 'base64 block of 4000+ chars' })
737
+ continue
738
+ }
739
+ if (text.length > 4096 && shannonEntropy(text) > 7.5) {
740
+ opaque.push({ relative: file.relative, clue: `high entropy ${shannonEntropy(text).toFixed(2)} over ${text.length} chars` })
741
+ }
742
+ }
743
+ for (const item of opaque.slice(0, 8)) {
744
+ details.push(detail('C-19.1', 'WARN', `opaque text clue: ${item.clue} in ${item.relative}`, [item.relative]))
745
+ }
746
+ for (const item of binaries.slice(0, 8)) {
747
+ details.push(detail('C-19.2', 'WARN', `binary artifact ${item.relative} (${item.size} bytes, sha256:${item.sha256}) is recorded, not judged`, [item.relative]))
748
+ }
749
+ if (opaque.length === 0 && binaries.length === 0) {
750
+ details.push(detail('C-19.1', 'PASS', 'no opaque text or binary artifact above the advisory thresholds'))
751
+ }
752
+ if (inventory.stats.truncated) {
753
+ details.push(detail('C-19.1', 'NA', 'not-run: file inventory was truncated; opaque artifacts beyond the budget were not scanned', []))
754
+ }
755
+ const result = opaque.length > 0 || binaries.length > 0 ? 'WARN' : 'PASS'
756
+ return check({
757
+ id: 'DSC-19',
758
+ clause: 'C-19',
759
+ layer: 'L0',
760
+ result,
761
+ coverage: inventory.stats.truncated ? 'partial' : 'full',
762
+ advisory: true,
763
+ reason: result === 'WARN' ? `${opaque.length} opaque text clue(s), ${binaries.length} binary artifact(s) (advisory)` : '',
764
+ details,
765
+ evidence: [...opaque, ...binaries].slice(0, 5).map(item => item.relative),
766
+ })
767
+ }
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // DSC-20: prompt-injection clues
771
+ // ---------------------------------------------------------------------------
772
+
773
+ function propertyKeyOf(ancestors) {
774
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
775
+ const node = ancestors[index]
776
+ if (node.type === 'Property') return keyName(node)
777
+ if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') continue
778
+ return null
779
+ }
780
+ return null
781
+ }
782
+
783
+ function isRuleContext(ancestors, node) {
784
+ const key = propertyKeyOf(ancestors)
785
+ if (key && RULE_KEYS.has(key)) return true
786
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
787
+ const ancestor = ancestors[index]
788
+ if (ancestor.type === 'NewExpression' && chainNames(ancestor.callee).join('.') === 'RegExp') return true
789
+ if (ancestor.type === 'CallExpression' && ancestor.callee.type === 'MemberExpression') {
790
+ const method = memberPropertyName(ancestor.callee)
791
+ if (['test', 'match', 'matchAll', 'exec', 'includes', 'compile', 'replace', 'split'].includes(method)) return true
792
+ }
793
+ }
794
+ return false
795
+ }
796
+
797
+ function collectParameterNames(ast) {
798
+ const names = new Set()
799
+ walkAst(ast, (node) => {
800
+ if (!['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type)) return
801
+ for (const parameter of node.params ?? []) {
802
+ if (parameter.type === 'Identifier') names.add(parameter.name)
803
+ if (parameter.type === 'AssignmentPattern' && parameter.left?.type === 'Identifier') names.add(parameter.left.name)
804
+ }
805
+ })
806
+ return names
807
+ }
808
+
809
+ function isExternalExpression(node, bindings, parameters, depth = 0, seen = new Set()) {
810
+ if (!node || depth > 4 || seen.has(node)) return false
811
+ if (node.type === 'Identifier') {
812
+ if (parameters.has(node.name)) return true
813
+ const init = bindings.get(node.name)
814
+ if (!init) return false
815
+ return isExternalExpression(init, bindings, parameters, depth + 1, new Set(seen).add(node))
816
+ }
817
+ if (node.type === 'AwaitExpression') return isExternalExpression(node.argument, bindings, parameters, depth + 1, new Set(seen).add(node))
818
+ if (node.type === 'TemplateLiteral') {
819
+ return node.expressions.some(expression => isExternalExpression(expression, bindings, parameters, depth + 1, new Set(seen).add(node)))
820
+ }
821
+ if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
822
+ return isExternalExpression(node.left, bindings, parameters, depth + 1, new Set(seen).add(node))
823
+ || isExternalExpression(node.right, bindings, parameters, depth + 1, new Set(seen).add(node))
824
+ }
825
+ if (node.type === 'MemberExpression') {
826
+ return ['tools', 'toolResult', 'messages', 'results'].includes(memberPropertyName(node))
827
+ }
828
+ if (node.type === 'CallExpression') {
829
+ const names = chainNames(node.callee)
830
+ const joined = names.join('.').toLowerCase()
831
+ return /fetch|request|tool|invoke|call|read|query|search|message|completion/.test(joined)
832
+ }
833
+ return false
834
+ }
835
+
836
+ export function checkPromptInjectionClues(manifest, sources) {
837
+ if (!sources || sources.length === 0) {
838
+ return check({
839
+ id: 'DSC-20', clause: 'C-20', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
840
+ reason: 'no source files scanned; DSC-20 not-run',
841
+ details: [detail('C-20.1', 'NA', 'not-run: no source files', [])],
842
+ evidence: [],
843
+ })
844
+ }
845
+ const details = []
846
+ const clues = []
847
+ for (const source of serverSources(sources)) {
848
+ if (RULE_FILE.test(source.relative)) continue
849
+ const parsed = parseModule(source)
850
+ if (parsed.error) continue
851
+ const parameters = collectParameterNames(parsed.ast)
852
+ walkAst(parsed.ast, (node, ancestors) => {
853
+ if (node.type !== 'CallExpression') return
854
+ const names = chainNames(node.callee)
855
+ if (!names.includes('systemPrompt')) return
856
+ const method = names[names.length - 1]
857
+ if (!['section', 'context', 'variable'].includes(method)) return
858
+ const argument = node.arguments[0]
859
+ if (!argument) return
860
+ if (isRuleContext(ancestors, node)) return
861
+ const property = propertyKeyOf(ancestors)
862
+ if (property && DOC_KEYS.has(property)) return
863
+ const literal = argument.type === 'TemplateLiteral'
864
+ ? stringFromNode(argument, parsed.bindings)
865
+ : literalStringValue(argument)
866
+ if (literal !== null) {
867
+ if (INSTRUCTION_PATTERN.test(literal)) {
868
+ clues.push({ file: source.relative, line: lineOf(node), what: 'instruction-like systemPrompt literal' })
869
+ }
870
+ return
871
+ }
872
+ if (isExternalExpression(argument, parsed.bindings, parameters)) {
873
+ clues.push({ file: source.relative, line: lineOf(node), what: 'systemPrompt argument derives from external input' })
874
+ }
875
+ })
876
+ }
877
+ for (const clue of clues.slice(0, 8)) {
878
+ 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}`]))
879
+ }
880
+ if (clues.length === 0) details.push(detail('C-20.1', 'PASS', 'no prompt-surface clue found (instruction-like literal or external systemPrompt argument)'))
881
+ return check({
882
+ id: 'DSC-20',
883
+ clause: 'C-20',
884
+ layer: 'L0',
885
+ result: clues.length > 0 ? 'WARN' : 'PASS',
886
+ coverage: 'full',
887
+ advisory: true,
888
+ reason: clues.length > 0 ? `${clues.length} prompt-injection clue(s); semantic intent is not judged` : '',
889
+ details,
890
+ evidence: clues.slice(0, 5).map(clue => `${clue.file}:${clue.line}`),
891
+ })
892
+ }
893
+
894
+ // ---------------------------------------------------------------------------
895
+ // DSC-21: supply-chain name similarity
896
+ // ---------------------------------------------------------------------------
897
+
898
+ const KNOWN_SEEDS = [
899
+ '@deepseek-ai/dsh', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-app-boot',
900
+ '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/dsh-credentials',
901
+ '@deepseek-ai/dsh-credentials-local', '@deepseek-ai/dsh-session',
902
+ '@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-fs-sandbox',
903
+ '@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-interaction', '@deepseek-ai/dsh-llm',
904
+ '@deepseek-ai/dsh-web', '@deepseek-ai/dsh-client', 'cordis',
905
+ '@cordisjs/core', 'schemastery', 'cosmokit',
906
+ ]
907
+ const KNOWN_SET = new Set(KNOWN_SEEDS)
908
+ const SEED_BASENAMES = [...new Set(KNOWN_SEEDS.map(name => name.split('/').pop()))]
909
+
910
+ function editDistanceWithin(left, right, limit) {
911
+ if (Math.abs(left.length - right.length) > limit) return false
912
+ const previous = new Array(right.length + 1).fill(0).map((_, index) => index)
913
+ for (let i = 1; i <= left.length; i += 1) {
914
+ let diagonal = previous[0]
915
+ previous[0] = i
916
+ let rowMin = previous[0]
917
+ for (let j = 1; j <= right.length; j += 1) {
918
+ const above = previous[j]
919
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1
920
+ previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + cost)
921
+ diagonal = above
922
+ rowMin = Math.min(rowMin, previous[j])
923
+ }
924
+ if (rowMin > limit) return false
925
+ }
926
+ return previous[right.length] <= limit
927
+ }
928
+
929
+ function normalizeHomoglyph(value) {
930
+ return String(value).toLowerCase().replace(/0/g, 'o').replace(/1/g, 'l').replace(/rn/g, 'm')
931
+ }
932
+
933
+ function scopeOf(name) {
934
+ return name.startsWith('@') ? name.split('/')[0] : null
935
+ }
936
+
937
+ export function checkSupplyChain(manifest) {
938
+ const dependencies = {
939
+ ...(manifest?.dependencies && typeof manifest.dependencies === 'object' ? manifest.dependencies : {}),
940
+ ...(manifest?.optionalDependencies && typeof manifest.optionalDependencies === 'object' ? manifest.optionalDependencies : {}),
941
+ ...(manifest?.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {}),
942
+ }
943
+ const names = Object.keys(dependencies)
944
+ const details = []
945
+ const findings = []
946
+ let knownCount = 0
947
+ for (const name of names) {
948
+ if (KNOWN_SET.has(name)) { knownCount += 1; continue }
949
+ const scope = scopeOf(name)
950
+ if (scope && scope !== '@deepseek-ai' && (editDistanceWithin(scope, '@deepseek-ai', 1) || normalizeHomoglyph(scope) === normalizeHomoglyph('@deepseek-ai'))) {
951
+ findings.push({ name, item: 'C-21.2', why: `dependency scope ${scope} is a near-miss of the official @deepseek-ai scope` })
952
+ continue
953
+ }
954
+ const basename = name.split('/').pop()
955
+ if (!/dsh/i.test(basename) && !/dsh/i.test(scope ?? '')) continue
956
+ const similar = SEED_BASENAMES.find(seed => seed !== basename && editDistanceWithin(basename, seed, 1))
957
+ if (similar) findings.push({ name, item: 'C-21.1', why: `dependency ${name} is one edit away from known package ${similar}` })
958
+ }
959
+ for (const finding of findings.slice(0, 8)) {
960
+ details.push(detail(finding.item, 'WARN', finding.why, [`package.json#dependencies.${finding.name}`]))
961
+ }
962
+ if (names.length === 0) {
963
+ details.push(detail('C-21.1', 'PASS', 'no runtime dependencies declared'))
964
+ return check({
965
+ id: 'DSC-21', clause: 'C-21', layer: 'L0', result: 'PASS', coverage: 'full', advisory: false,
966
+ reason: '', details, evidence: ['package.json#dependencies'],
967
+ })
968
+ }
969
+ 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', []))
970
+ if (findings.length === 0) {
971
+ details.push(detail('C-21.1', 'PASS', `${names.length} dependency name(s) checked; ${knownCount} exact known name(s)`))
972
+ }
973
+ return check({
974
+ id: 'DSC-21',
975
+ clause: 'C-21',
976
+ layer: 'L0',
977
+ result: findings.length > 0 ? 'WARN' : 'PASS',
978
+ coverage: 'partial',
979
+ advisory: false,
980
+ reason: findings.length > 0 ? `${findings.length} supply-chain name clue(s)` : '',
981
+ details,
982
+ evidence: findings.slice(0, 5).map(finding => `package.json#dependencies.${finding.name}`),
983
+ })
984
+ }
985
+
986
+ // ---------------------------------------------------------------------------
987
+ // DSC-22: provenance / integrity (advisory only)
988
+ // ---------------------------------------------------------------------------
989
+
990
+ export function checkProvenance(manifest) {
991
+ const integrity = manifest?.dist?.integrity
992
+ const attestation = manifest?.dsh?.attestation
993
+ const signature = manifest?.dsh?.signature
994
+ const details = []
995
+ if (typeof integrity === 'string' && integrity.trim() !== '') {
996
+ details.push(detail('C-22.1', 'PASS', 'dist.integrity is present; this is integrity-only and does not prove provenance', ['package.json#dist.integrity']))
997
+ return check({
998
+ id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
999
+ reason: '', details, evidence: ['package.json#dist.integrity'],
1000
+ })
1001
+ }
1002
+ if ((typeof attestation === 'object' && attestation !== null) || (typeof signature === 'object' && signature !== null) || typeof attestation === 'string' || typeof signature === 'string') {
1003
+ 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']))
1004
+ return check({
1005
+ id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
1006
+ reason: '', details, evidence: ['package.json#dsh.attestation'],
1007
+ })
1008
+ }
1009
+ details.push(detail('C-22.1', 'NA', 'not-run: no registry metadata or attestation supplied; provenance is not judged', []))
1010
+ return check({
1011
+ id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
1012
+ reason: 'no registry metadata/attestation input; not-run',
1013
+ details, evidence: [],
1014
+ })
1015
+ }
1016
+
1017
+ // ---------------------------------------------------------------------------
1018
+ // aggregate
1019
+ // ---------------------------------------------------------------------------
1020
+
1021
+ function unavailable(reason) {
1022
+ return SECURITY_IDS.map(id => check({
1023
+ id,
1024
+ clause: SECURITY_CLAUSE[id],
1025
+ layer: 'L0',
1026
+ result: 'ERROR',
1027
+ coverage: 'not-run',
1028
+ advisory: SECURITY_ADVISORY.has(id),
1029
+ reason,
1030
+ details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${reason}`, [])],
1031
+ evidence: [],
1032
+ }))
1033
+ }
1034
+
1035
+ /** Run all eight security checks. Never throws: a failure becomes not-run rows. */
1036
+ export function runSecurityChecks({ dir, manifest, sources = [], entry = null, kind = null } = {}) {
1037
+ if (!manifest) return unavailable('manifest unavailable')
1038
+ const checks = []
1039
+ const run = (id, factory) => {
1040
+ try {
1041
+ checks.push(factory())
1042
+ } catch (error) {
1043
+ checks.push(check({
1044
+ id,
1045
+ clause: SECURITY_CLAUSE[id],
1046
+ layer: 'L0',
1047
+ result: 'ERROR',
1048
+ coverage: 'not-run',
1049
+ advisory: SECURITY_ADVISORY.has(id),
1050
+ reason: `checker error: ${error?.message ?? error}`,
1051
+ details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${error?.message ?? error}`, [])],
1052
+ evidence: [],
1053
+ }))
1054
+ }
1055
+ }
1056
+ run('DSC-15', () => checkLifecycleScripts(manifest, dir))
1057
+ run('DSC-16', () => checkDynamicExecution(manifest, sources, entry, kind))
1058
+ run('DSC-17', () => checkSensitiveFileReads(manifest, sources))
1059
+ run('DSC-18', () => checkEgressEndpoints(manifest, sources))
1060
+ run('DSC-19', () => checkOpaqueArtifacts(dir, sources))
1061
+ run('DSC-20', () => checkPromptInjectionClues(manifest, sources))
1062
+ run('DSC-21', () => checkSupplyChain(manifest))
1063
+ run('DSC-22', () => checkProvenance(manifest))
1064
+ return checks
1065
+ }