dsh-standards 1.6.1 → 1.6.3

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 (53) hide show
  1. package/40-checker/fixtures/security/sec11-cred-declared/index.mjs +3 -0
  2. package/40-checker/fixtures/security/sec11-cred-declared/package.json +21 -0
  3. package/40-checker/fixtures/security/sec11-cred-list/index.mjs +3 -0
  4. package/40-checker/fixtures/security/sec11-cred-list/package.json +21 -0
  5. package/40-checker/fixtures/security/sec11-cred-undeclared/index.mjs +3 -0
  6. package/40-checker/fixtures/security/sec11-cred-undeclared/package.json +19 -0
  7. package/40-checker/fixtures/security/sec15-install-download-safe/index.js +2 -0
  8. package/40-checker/fixtures/security/sec15-install-download-safe/package.json +22 -0
  9. package/40-checker/fixtures/security/sec15-install-download-safe/scripts/install.js +11 -0
  10. package/40-checker/fixtures/security/sec15-install-download-safe/scripts/local-util.cjs +1 -0
  11. package/40-checker/fixtures/security/sec15-install-dynamic/index.js +2 -0
  12. package/40-checker/fixtures/security/sec15-install-dynamic/package.json +22 -0
  13. package/40-checker/fixtures/security/sec15-install-dynamic/scripts/install.js +11 -0
  14. package/40-checker/fixtures/security/sec15-install-remote-literal/index.js +1 -0
  15. package/40-checker/fixtures/security/sec15-install-remote-literal/package.json +8 -0
  16. package/40-checker/fixtures/security/sec15-install-remote-literal/scripts/install.js +6 -0
  17. package/40-checker/fixtures/security/sec16-base64-clean/index.mjs +4 -0
  18. package/40-checker/fixtures/security/sec16-base64-clean/package.json +19 -0
  19. package/40-checker/fixtures/security/sec16-base64-eval/index.mjs +4 -0
  20. package/40-checker/fixtures/security/sec16-base64-eval/package.json +19 -0
  21. package/40-checker/fixtures/security/sec16-exec-curl/index.mjs +4 -0
  22. package/40-checker/fixtures/security/sec16-exec-curl/package.json +19 -0
  23. package/40-checker/fixtures/security/sec16-import-only/index.mjs +4 -0
  24. package/40-checker/fixtures/security/sec16-import-only/package.json +19 -0
  25. package/40-checker/fixtures/security/sec16-spawn-shell/index.mjs +4 -0
  26. package/40-checker/fixtures/security/sec16-spawn-shell/package.json +19 -0
  27. package/40-checker/fixtures/security/sec16-subprocess-safe/index.mjs +4 -0
  28. package/40-checker/fixtures/security/sec16-subprocess-safe/package.json +19 -0
  29. package/40-checker/fixtures/security/sec17-alias-clean/data.txt +1 -0
  30. package/40-checker/fixtures/security/sec17-alias-clean/index.mjs +5 -0
  31. package/40-checker/fixtures/security/sec17-alias-clean/package.json +19 -0
  32. package/40-checker/fixtures/security/sec17-alias-read/index.mjs +7 -0
  33. package/40-checker/fixtures/security/sec17-alias-read/package.json +19 -0
  34. package/40-checker/fixtures/security/sec17-base64-path/index.mjs +11 -0
  35. package/40-checker/fixtures/security/sec17-base64-path/package.json +19 -0
  36. package/40-checker/fixtures/security/sec17-base64-path-clean/data/config.json +1 -0
  37. package/40-checker/fixtures/security/sec17-base64-path-clean/index.mjs +5 -0
  38. package/40-checker/fixtures/security/sec17-base64-path-clean/package.json +19 -0
  39. package/40-checker/fixtures/security/sec17-home-env/index.js +8 -0
  40. package/40-checker/fixtures/security/sec17-home-env/package.json +19 -0
  41. package/40-checker/fixtures/security/sec17-home-env-clean/index.mjs +6 -0
  42. package/40-checker/fixtures/security/sec17-home-env-clean/package.json +19 -0
  43. package/40-checker/fixtures/security/sec18-split-fetch/index.mjs +5 -0
  44. package/40-checker/fixtures/security/sec18-split-fetch/package.json +19 -0
  45. package/40-checker/fixtures/security/sec18-split-fetch-clean/index.mjs +4 -0
  46. package/40-checker/fixtures/security/sec18-split-fetch-clean/package.json +24 -0
  47. package/40-checker/src/declarations.mjs +6 -1
  48. package/40-checker/src/remediation.mjs +1 -1
  49. package/40-checker/src/security.mjs +258 -22
  50. package/CHANGELOG.md +16 -0
  51. package/docs/coverage.md +17 -7
  52. package/package.json +1 -1
  53. package/spec/coverage-contract.md +10 -7
@@ -45,6 +45,18 @@ const READ_APIS = new Set([
45
45
  'open', 'openSync', 'createReadStream', 'readdir', 'readdirSync',
46
46
  ])
47
47
  const CHILD_PROCESS = /^(?:node:)?child_process$/
48
+ const COMMAND_EXEC_APIS = new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync'])
49
+ // A subprocess call is only a FAIL when its statically-known command is
50
+ // unambiguously a download-and-run / obfuscated payload, never for a plain
51
+ // `git status` or an import that is never called.
52
+ const DANGEROUS_COMMAND = [
53
+ { pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
54
+ { pattern: /\b(?:curl|wget)\b[^\n]{0,200}(?:&&|;|\|\|)\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish|node)\b/, label: 'downloaded file executed by a shell' },
55
+ { pattern: /\bbase64\s+(?:-d|--decode|-D)\b/, label: 'base64 decode command' },
56
+ { pattern: /\bnode\s+(?:-e|--eval)\b/, label: 'inline node code' },
57
+ { pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
58
+ { pattern: /\b(?:bash|sh|zsh|dash|ksh)\s+-[a-z]*c\b[^\n]{0,200}\b(?:curl|wget|base64|nc|eval)\b/, label: 'shell -c with a download/decode payload' },
59
+ ]
48
60
  const INSTRUCTION_PATTERN = /\b(?:ignore|disregard|forget)\b[^\n]{0,60}\b(?:previous|prior|above|earlier|system)\b|\byou are now\b|\bdo not (?:tell|reveal|mention|disclose)\b|\bsystem prompt\b|\bact as\b/i
49
61
  const SUSPICIOUS_HOST = /(?:^|\.)(?:webhook|pastebin|paste|ngrok\.io|ngrok-free\.app|requestbin|pipedream|interact\.sh|burpcollaborator|canarytokens)\./i
50
62
  const TEXT_EXTENSIONS = /\.(?:[cm]?[jt]sx?|json|md|txt|ya?ml|html|css)$/i
@@ -122,6 +134,91 @@ function collectBindings(ast) {
122
134
  return bindings
123
135
  }
124
136
 
137
+ const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/
138
+
139
+ /** Decode a base64 literal only when the result is printable text. */
140
+ function decodeBase64Literal(text) {
141
+ if (typeof text !== 'string' || text.length < 8 || text.length % 4 !== 0) return null
142
+ if (!BASE64_TEXT.test(text)) return null
143
+ let decoded
144
+ try {
145
+ decoded = Buffer.from(text, 'base64').toString('utf8')
146
+ } catch {
147
+ return null
148
+ }
149
+ if (decoded === '' || decoded.includes('\uFFFD')) return null
150
+ if (!/^[\t\n\r\x20-\x7e]*$/.test(decoded)) return null
151
+ return decoded
152
+ }
153
+
154
+ /**
155
+ * `Buffer.from(<string>, 'base64').toString()` / `atob(<string>)` when the
156
+ * payload is a static literal (also reachable through a local binding).
157
+ * Returns null for runtime-joined or non-base64 encodings.
158
+ */
159
+ function base64DecodedFrom(node, bindings, depth = 0, seen = new Set()) {
160
+ if (!node || node.type !== 'CallExpression' || depth > 4 || seen.has(node)) return null
161
+ const names = chainNames(node.callee)
162
+ const last = names[names.length - 1]
163
+ if (last === 'atob') {
164
+ const payload = stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
165
+ return payload === null ? null : decodeBase64Literal(payload)
166
+ }
167
+ if (last !== 'from' || names[names.length - 2] !== 'Buffer') return null
168
+ const encodingNode = node.arguments[1]
169
+ const encoding = encodingNode === undefined ? 'utf8' : (encodingNode.type === 'Literal' ? encodingNode.value : null)
170
+ if (encoding !== 'base64' && encoding !== 'base64url') return null
171
+ const payload = stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
172
+ return payload === null ? null : decodeBase64Literal(payload)
173
+ }
174
+
175
+ function isBase64DecodeCall(node, bindings) {
176
+ if (!node || node.type !== 'CallExpression') return false
177
+ const names = chainNames(node.callee)
178
+ const last = names[names.length - 1]
179
+ if (last === 'from' && names[names.length - 2] === 'Buffer') {
180
+ const encoding = node.arguments[1]
181
+ if (encoding?.type !== 'Literal' || (encoding.value !== 'base64' && encoding.value !== 'base64url')) return false
182
+ return stringFromNode(node.arguments[0], bindings) !== null
183
+ }
184
+ if (last === 'toString' && node.callee.type === 'MemberExpression') {
185
+ const encoding = node.arguments[0]
186
+ if (encoding?.type === 'Literal' && (encoding.value === 'base64' || encoding.value === 'base64url')) return false
187
+ return base64DecodedFrom(node.callee.object, bindings) !== null
188
+ }
189
+ if (last === 'atob' && names[0] === 'atob') return base64DecodedFrom(node, bindings) !== null
190
+ return false
191
+ }
192
+
193
+ function isProcessLike(node) {
194
+ if (identifierName(node) === 'process') return true
195
+ return node?.type === 'MemberExpression'
196
+ && memberPropertyName(node) === 'process'
197
+ && ['globalThis', 'global', 'window', 'self'].includes(identifierName(node.object))
198
+ }
199
+
200
+ /**
201
+ * `process.env.HOME` / `process.env['DSH_HOME']` / `globalThis.process.env.HOME`.
202
+ * The old check compared identifierName(node.object) with 'process', but
203
+ * node.object is the `process.env` MemberExpression, so it never matched.
204
+ */
205
+ function envPropertyOf(member) {
206
+ if (!member || member.type !== 'MemberExpression') return null
207
+ const env = member.object
208
+ if (env?.type !== 'MemberExpression' || memberPropertyName(env) !== 'env') return null
209
+ if (!isProcessLike(env.object)) return null
210
+ return memberPropertyName(member)
211
+ }
212
+
213
+ /** Property name with static computed keys folded: globalThis['fe' + 'tch']. */
214
+ function foldedPropertyName(member, bindings) {
215
+ if (!member || member.type !== 'MemberExpression') return null
216
+ const direct = memberPropertyName(member)
217
+ if (direct !== null) return direct
218
+ if (!member.computed) return null
219
+ return stringFromNode(member.property, bindings)
220
+ }
221
+
125
222
  function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
126
223
  if (!node || depth > 5 || seen.has(node)) return null
127
224
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value
@@ -161,12 +258,9 @@ function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
161
258
  const property = memberPropertyName(node)
162
259
  const objectName = identifierName(node.object)
163
260
  if (objectName === 'process' && property === 'cwd') return '/work/package'
164
- if (objectName === 'process' && node.object.type === 'MemberExpression'
165
- && memberPropertyName(node.object) === 'env'
166
- && ['HOME', 'USERPROFILE'].includes(property)) return '/home/dsh-user'
167
- if (objectName === 'process' && node.object.type === 'MemberExpression'
168
- && memberPropertyName(node.object) === 'env'
169
- && ['DSH_HOME', 'XDG_CONFIG_HOME'].includes(property)) return '/home/dsh-user/.dsh'
261
+ const envProperty = envPropertyOf(node)
262
+ if (envProperty === 'HOME' || envProperty === 'USERPROFILE') return '/home/dsh-user'
263
+ if (envProperty === 'DSH_HOME' || envProperty === 'XDG_CONFIG_HOME') return '/home/dsh-user/.dsh'
170
264
  return null
171
265
  }
172
266
  if (node.type === 'CallExpression') {
@@ -194,7 +288,22 @@ function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
194
288
  }
195
289
  if (last === 'toString') {
196
290
  const object = node.callee.type === 'MemberExpression' ? node.callee.object : null
291
+ const encoding = node.arguments[0]
292
+ const reEncoding = encoding?.type === 'Literal' && (encoding.value === 'base64' || encoding.value === 'base64url')
293
+ if (!reEncoding) {
294
+ const decoded = base64DecodedFrom(object, bindings, depth + 1, new Set(seen).add(node))
295
+ if (decoded !== null) return decoded
296
+ }
197
297
  if (object?.type === 'Literal') return String(object.value)
298
+ return null
299
+ }
300
+ if (last === 'atob' && names[0] === 'atob') {
301
+ const payload = stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
302
+ if (payload !== null) {
303
+ const decoded = decodeBase64Literal(payload)
304
+ if (decoded !== null) return decoded
305
+ }
306
+ return null
198
307
  }
199
308
  if (names[0] === 'String') return stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
200
309
  return null
@@ -272,6 +381,9 @@ const SCRIPT_DANGER_RULES = [
272
381
  ]
273
382
  const SCRIPT_NETWORK = /\b(?:fetch|axios\.(?:get|post)|https?\.(?:get|request))\s*\(|\bcurl\b|\bwget\b/
274
383
  const SCRIPT_EXECUTE = /\beval\b|\bnew\s+Function\b|\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(|\bvm\.(?:runIn\w+|compileFunction)/
384
+ // require(<variable>) / import(<variable>): loading code that is not a static
385
+ // literal. Only dangerous when the same script also downloads or pipes code.
386
+ const SCRIPT_DYNAMIC_LOAD = /\brequire\s*\(\s*(?!['"`])[A-Za-z_$]|\bimport\s*\(\s*(?!['"`])[A-Za-z_$]|\b(?:require|import)\s*\(\s*['"`](?:\/|\.\.\/|https?:\/\/)/
275
387
  const SCRIPT_CHILD_DYNAMIC = [
276
388
  /\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*`[^`]*\$\{/,
277
389
  /\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*[A-Za-z_$][\w$]*\s*[,)]/,
@@ -282,6 +394,7 @@ function scriptDangerLabels(text) {
282
394
  const add = (label) => { if (!labels.includes(label)) labels.push(label) }
283
395
  for (const rule of SCRIPT_DANGER_RULES) if (rule.pattern.test(text)) add(rule.label)
284
396
  if (SCRIPT_NETWORK.test(text) && SCRIPT_EXECUTE.test(text)) add('downloads remote content and executes it')
397
+ if (SCRIPT_NETWORK.test(text) && SCRIPT_DYNAMIC_LOAD.test(text)) add('downloads remote content and dynamically loads it')
285
398
  if (/\bchild_process\b/.test(text) && SCRIPT_CHILD_DYNAMIC.some(pattern => pattern.test(text))) {
286
399
  add('child_process with a dynamic command')
287
400
  }
@@ -435,6 +548,50 @@ function functionConstructorExempt(node, ancestors) {
435
548
  return allowsEvalContext(ancestors)
436
549
  }
437
550
 
551
+ /** exec/execSync/spawn/... called directly or through a one-level alias. */
552
+ function commandExecApiOf(callee, bindings) {
553
+ if (!callee) return null
554
+ if (callee.type === 'Identifier') {
555
+ if (COMMAND_EXEC_APIS.has(callee.name)) return callee.name
556
+ const init = bindings.get(callee.name)
557
+ if (init?.type === 'MemberExpression') {
558
+ const property = memberPropertyName(init)
559
+ if (property && COMMAND_EXEC_APIS.has(property)) return property
560
+ }
561
+ return null
562
+ }
563
+ if (callee.type === 'MemberExpression') {
564
+ const property = memberPropertyName(callee)
565
+ if (property && COMMAND_EXEC_APIS.has(property)) return property
566
+ }
567
+ return null
568
+ }
569
+
570
+ /** Join the statically resolvable command/argv parts; options objects are skipped. */
571
+ function commandTextOf(node, bindings) {
572
+ const parts = []
573
+ for (const argument of node.arguments ?? []) {
574
+ if (!argument || argument.type === 'ObjectExpression') continue
575
+ if (argument.type === 'ArrayExpression') {
576
+ for (const element of argument.elements) {
577
+ if (!element) continue
578
+ const value = stringFromNode(element, bindings)
579
+ if (value !== null) parts.push(value)
580
+ }
581
+ continue
582
+ }
583
+ const value = stringFromNode(argument, bindings)
584
+ if (value !== null) parts.push(value)
585
+ }
586
+ return parts.length > 0 ? parts.join(' ') : null
587
+ }
588
+
589
+ function dangerousCommandLabels(text) {
590
+ const labels = []
591
+ for (const rule of DANGEROUS_COMMAND) if (rule.pattern.test(text)) labels.push(rule.label)
592
+ return labels
593
+ }
594
+
438
595
  export function checkDynamicExecution(manifest, sources, entry, kind = null) {
439
596
  if (kind === 'client') {
440
597
  return check({
@@ -464,10 +621,19 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
464
621
  const details = [...closure.notes]
465
622
  const dynamic = []
466
623
  const subprocess = []
624
+ const fileFacts = new Map()
467
625
  for (const relative of closure.visited) {
468
626
  const parsed = parseModule(closure.byRelative.get(relative))
469
627
  if (parsed.error) continue
628
+ let facts = fileFacts.get(relative)
629
+ if (!facts) {
630
+ facts = { base64Line: null, evalLike: false }
631
+ fileFacts.set(relative, facts)
632
+ }
470
633
  walkAst(parsed.ast, (node, ancestors) => {
634
+ if (isBase64DecodeCall(node, parsed.bindings)) {
635
+ if (facts.base64Line === null) facts.base64Line = lineOf(node)
636
+ }
471
637
  if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
472
638
  subprocess.push({ file: relative, line: lineOf(node), what: `import ${node.source.value}` })
473
639
  return
@@ -482,13 +648,26 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
482
648
  subprocess.push({ file: relative, line: lineOf(node), what: `import('${node.source.value}')` })
483
649
  }
484
650
  if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'eval') {
651
+ facts.evalLike = true
485
652
  dynamic.push({ file: relative, line: lineOf(node), what: 'eval()', item: 'C-16.1' })
486
653
  return
487
654
  }
488
655
  if (isFunctionConstructor(node)) {
656
+ facts.evalLike = true
489
657
  if (!functionConstructorExempt(node, ancestors)) dynamic.push({ file: relative, line: lineOf(node), what: 'new Function()', item: 'C-16.1' })
490
658
  return
491
659
  }
660
+ if (node.type === 'CallExpression') {
661
+ const execApi = commandExecApiOf(node.callee, parsed.bindings)
662
+ if (execApi) {
663
+ const command = commandTextOf(node, parsed.bindings)
664
+ const labels = command === null ? [] : dangerousCommandLabels(command)
665
+ if (labels.length > 0) {
666
+ dynamic.push({ file: relative, line: lineOf(node), what: `child_process.${execApi} executes a dangerous command (${labels.join('; ')})`, item: 'C-16.1' })
667
+ return
668
+ }
669
+ }
670
+ }
492
671
  if (node.type === 'CallExpression' || node.type === 'NewExpression') {
493
672
  const names = chainNames(node.callee)
494
673
  const joined = names.join('.')
@@ -505,11 +684,21 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
505
684
  }
506
685
  })
507
686
  }
687
+ const obfuscated = []
688
+ for (const [file, facts] of fileFacts) {
689
+ if (facts.base64Line !== null && facts.evalLike) {
690
+ obfuscated.push({ file, line: facts.base64Line, what: 'base64-decoded payload in the same file as eval/new Function' })
691
+ }
692
+ }
508
693
  const dynamicEvidence = dynamic.slice(0, 5).map(item => `${item.file}:${item.line}`)
694
+ const obfuscatedEvidence = obfuscated.slice(0, 5).map(item => `${item.file}:${item.line}`)
509
695
  const subprocessEvidence = subprocess.slice(0, 5).map(item => `${item.file}:${item.line}`)
510
696
  for (const item of dynamic.slice(0, 8)) {
511
697
  details.push(detail(item.item, 'FAIL', `dynamic execution (code:dynamic): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
512
698
  }
699
+ for (const item of obfuscated.slice(0, 4)) {
700
+ details.push(detail('C-16.1', 'FAIL', `dynamic execution (code:obfuscated): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
701
+ }
513
702
  for (const item of subprocess.slice(0, 8)) {
514
703
  details.push(detail('C-16.3', 'WARN', `subprocess capability observed: ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
515
704
  }
@@ -522,9 +711,10 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
522
711
  const coverage = closure.parseErrors.length > 0 || closure.packageImports.length > 0 || closure.unresolved.length > 0 ? 'partial' : 'full'
523
712
  let result
524
713
  let reason = ''
525
- if (dynamic.length > 0) {
714
+ if (dynamic.length > 0 || obfuscated.length > 0) {
526
715
  result = 'FAIL'
527
716
  reason = `${dynamic.length} dynamic-execution site(s) in the server entry closure (code:dynamic)`
717
+ if (obfuscated.length > 0) reason += `; ${obfuscated.length} base64-obfuscated execution clue(s)`
528
718
  if (closure.parseErrors.length > 0) reason += `; ${closure.parseErrors.length} file(s) could not be parsed`
529
719
  } else if (closure.parseErrors.length > 0) {
530
720
  result = 'ERROR'
@@ -544,7 +734,7 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
544
734
  coverage: result === 'ERROR' ? 'not-run' : coverage,
545
735
  reason,
546
736
  details,
547
- evidence: dynamicEvidence.length > 0 ? dynamicEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
737
+ evidence: dynamicEvidence.length > 0 ? dynamicEvidence : obfuscatedEvidence.length > 0 ? obfuscatedEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
548
738
  })
549
739
  }
550
740
 
@@ -700,12 +890,36 @@ function securityScopeOf(ancestors, root) {
700
890
  return root
701
891
  }
702
892
 
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)
893
+ function resolveMemberCallee(callee, bindings, depth = 0, seen = new Set()) {
894
+ if (!callee || depth > 3 || seen.has(callee)) return null
895
+ if (callee.type === 'MemberExpression') return callee
896
+ if (callee.type !== 'Identifier') return null
897
+ const init = bindings.get(callee.name)
898
+ if (!init) return null
899
+ if (init.type === 'MemberExpression') return init
900
+ if (init.type === 'Identifier') return resolveMemberCallee(init, bindings, depth + 1, new Set(seen).add(callee))
901
+ return null
902
+ }
903
+
904
+ /**
905
+ * fetch / http(s).get called directly, through a one-level alias, or through a
906
+ * statically folded computed property such as globalThis['fe' + 'tch'].
907
+ */
908
+ function egressKindOf(node, bindings) {
909
+ if (!node || node.type !== 'CallExpression') return null
910
+ const callee = node.callee
911
+ if (callee.type === 'Identifier' && callee.name === 'fetch') return 'fetch'
912
+ const member = resolveMemberCallee(callee, bindings)
913
+ if (!member) return null
914
+ const name = foldedPropertyName(member, bindings)
915
+ if (name === 'fetch') return 'fetch'
916
+ const objectName = identifierName(member.object)
917
+ if ((objectName === 'http' || objectName === 'https') && (name === 'get' || name === 'request')) return name
918
+ return null
919
+ }
920
+
921
+ function isEgressCall(node, bindings) {
922
+ return egressKindOf(node, bindings) !== null
709
923
  }
710
924
 
711
925
  function isEnvEnumeration(node) {
@@ -718,14 +932,37 @@ function isEnvEnumeration(node) {
718
932
  && memberPropertyName(node.arguments[0]) === 'env'
719
933
  }
720
934
 
721
- function hasEgressCall(ast) {
935
+ function hasEgressCall(ast, bindings) {
722
936
  let found = false
723
937
  walkAst(ast, (node) => {
724
- if (!found && isEgressCall(node)) found = true
938
+ if (!found && isEgressCall(node, bindings)) found = true
725
939
  })
726
940
  return found
727
941
  }
728
942
 
943
+ /**
944
+ * Direct read API or a one-level alias: `const read = fs.readFileSync;
945
+ * read(path)`. Only a direct identifier declaration is resolved; destructuring
946
+ * aliases such as `{ readFileSync: read }` remain out of scope.
947
+ */
948
+ function readApiOf(callee, bindings) {
949
+ if (!callee) return null
950
+ if (callee.type === 'Identifier') {
951
+ if (READ_APIS.has(callee.name)) return callee.name
952
+ const init = bindings.get(callee.name)
953
+ if (init?.type === 'MemberExpression') {
954
+ const property = memberPropertyName(init)
955
+ if (property && READ_APIS.has(property)) return property
956
+ }
957
+ return null
958
+ }
959
+ if (callee.type === 'MemberExpression') {
960
+ const property = memberPropertyName(callee)
961
+ return property && READ_APIS.has(property) ? property : null
962
+ }
963
+ return null
964
+ }
965
+
729
966
  export function checkSensitiveFileReads(manifest, sources) {
730
967
  if (!sources || sources.length === 0) {
731
968
  return check({
@@ -751,7 +988,7 @@ export function checkSensitiveFileReads(manifest, sources) {
751
988
  facts = { secretLiteral: null, egress: false, reads: [] }
752
989
  scopeFacts.set(scope, facts)
753
990
  }
754
- if (isEgressCall(node)) facts.egress = true
991
+ if (isEgressCall(node, parsed.bindings)) facts.egress = true
755
992
  const literal = literalPathText(node)
756
993
  if (literal !== null) {
757
994
  const strength = isPathLikeSecretLiteral(node, literal)
@@ -759,8 +996,8 @@ export function checkSensitiveFileReads(manifest, sources) {
759
996
  }
760
997
  if (isEnvEnumeration(node)) envEnumeration = true
761
998
  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
999
+ const api = readApiOf(node.callee, parsed.bindings)
1000
+ if (!api) return
764
1001
  const argument = node.arguments[0]
765
1002
  facts.reads.push({
766
1003
  api,
@@ -813,7 +1050,7 @@ export function checkSensitiveFileReads(manifest, sources) {
813
1050
  }
814
1051
  }
815
1052
  }
816
- if (envEnumeration && hasEgressCall(parsed.ast)) {
1053
+ if (envEnumeration && hasEgressCall(parsed.ast, parsed.bindings)) {
817
1054
  warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', item: 'C-17.2', reason: 'environment enumeration with egress' })
818
1055
  }
819
1056
  }
@@ -904,8 +1141,7 @@ export function checkEgressEndpoints(manifest, sources) {
904
1141
  let target = null
905
1142
  if (node.type === 'CallExpression') {
906
1143
  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') {
1144
+ if (egressKindOf(node, parsed.bindings) !== null || names[0] === 'axios') {
909
1145
  target = egressEndpointFrom(node.arguments[0], parsed.bindings)
910
1146
  }
911
1147
  } else if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') {
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 1.6.3 — 2026-11-07
2
+
3
+ - DSC-15: an install script that downloads code and then loads a literal path outside the package (`require('/tmp/...')`, URL, `../`) now FAILs; the variable form was already covered.
4
+
5
+ ## 1.6.2 — 2026-11-07
6
+
7
+ ### Fixed
8
+
9
+ - 红队加固:`process.env.HOME` 拼接凭据路径、`exec/spawn` 危险命令、安装脚本「下载后动态 require」、一层 read 别名、base64+eval、拆分 fetch 均能命中;恶意变体阻断从 1/11 升到 7/11,负对照误报 0/12。
10
+ - `ctx.credentials` 触发补入 C-11.2,声明 credentials:read 不再反报 C-11.5。
11
+ - 文档口径:C-15 读脚本正文、C-17 readdir 可判、C-21 WARN-only、coverage 12/14、security 块 partial/读法、SECURITY 支持版本、advice 链接改 guide 0.5.0(security.md)。
12
+ - 10 个真实包 DSC-15/17 FAIL 0/10。
13
+
14
+ ### Verified
15
+
16
+ - `npm test` 282/282。
1
17
  ## 1.6.1 — 2026-11-07
2
18
 
3
19
  ### Fixed
package/docs/coverage.md CHANGED
@@ -15,8 +15,10 @@ dsh-standards check <package-dir> \
15
15
  |---|---|---|
16
16
  | 完整口径 | **22** | `DSC-01` … `DSC-22`;`--scope security` 时 `checks[]` 带全 22 项 |
17
17
  | 默认 `checks[]` | **14** | `DSC-01` … `DSC-14`;默认 DS-C 判定只看这 14 项 |
18
- | 默认实际评估 | **6** | `DSC-01`、`DSC-02`、`DSC-10`、`DSC-11`、`DSC-12`、`DSC-13` |
19
- | 默认未检查 | **8** | `DSC-03`(NA)+ `DSC-04`–`DSC-09`(not-run)+ `DSC-14`(NA) |
18
+ | 默认 ds-c 实际评估(bundle) | **12 / 14** | bundle 的 `DSC-04`–`09` 以静态子集 `PASS`+`partial` 计入;`DSC-03`/`DSC-14` 为 NA |
19
+ | 默认 ds-c 实际评估(plugin,无 `--runtime`) | **6 / 14** | `DSC-04`–`09` 为 not-run;`DSC-03`/`DSC-14` 为 NA |
20
+ | 默认未检查(plugin 静态) | **8** | 2 NA + 6 not-run;不等于通过 |
21
+ | `--scope security` `checks[]` | **22** | 加 `DSC-15`–`DSC-22`;`coverage.mandatory=4`(15/16/17/21),仍可能 not-run |
20
22
  | 默认 security 块 | **8** | `DSC-15`–`DSC-22` 在 `security` 块里跑 advisory;FAIL 封顶 WARN,不改变 DS-C 结论 |
21
23
 
22
24
  `not checked` 桶把 NA / not-run / ERROR 都列出来;**没检查不等于通过**。`DSC-10` 是 advisory,只提供信号,不改变结论。
@@ -45,7 +47,7 @@ dsh-standards check <package-dir> \
45
47
  | `DSC-18` | L0 | **advisory(security 块)** | 端点线索,永不 FAIL | `--scope security`;外联端点 |
46
48
  | `DSC-19` | L0 | **advisory(security 块)** | 只登记不判恶 | `--scope security`;混淆/二进制 |
47
49
  | `DSC-20` | L0 | **advisory(security 块)** | 语义不可判、误报高 | `--scope security`;提示词注入 |
48
- | `DSC-21` | L0 | **advisory WARN(security 块)** | 名字线索;默认 scope 不 FAIL | `--scope security`;供应链,强制项但只 WARN |
50
+ | `DSC-21` | L0 | **WARN(security 块)** | 名字线索;默认 scope 不 FAIL | `--scope security`;供应链,强制项但只 WARN、不阻断 |
49
51
  | `DSC-22` | L0 | **advisory(security 块)** | 只有字段自证 | `--scope security`;来源/完整性,v1 只读字段 |
50
52
 
51
53
  ## security scope(C-15~C-22)
@@ -55,17 +57,21 @@ dsh-standards check <package-dir> \
55
57
  | 项 | security scope 判定 | 触发 |
56
58
  |---|---|---|
57
59
  | `DSC-15` 生命周期脚本 | **blocking**(可 FAIL) | 安装期 `curl\|sh`、`base64 -d`、`node -e`、`sh -c`、`powershell -enc`、引用缺失脚本、写 `$HOME`/`/etc` |
58
- | `DSC-16` 动态执行 | **blocking**(可 FAIL) | `eval`/`new Function(非空)`/`vm`/`module._compile`/`process.binding` → FAIL;裸 `child_process` → WARN |
60
+ | `DSC-16` 动态执行 | **blocking**(可 FAIL) | `eval`/`new Function(非空)`/`vm`/`module._compile`/`process.binding` → FAIL;裸 `child_process` → WARN;静态危险子进程命令 1.6.2 起 FAIL |
59
61
  | `DSC-17` 敏感文件 | **blocking**(可 FAIL) | 直接读 `~/.ssh`、`$DSH_HOME` credentials、`id_rsa`、`.aws`、`.npmrc`、`.netrc` 等;`.env` 只 WARN |
60
62
  | `DSC-18` 外联端点 | **advisory**(永不 FAIL) | 非 https、userinfo、公网裸 IP、webhook/paste/ngrok/onion、未披露端点 |
61
63
  | `DSC-19` 混淆/二进制 | **advisory** | 二进制 sha256、超长单行、大块 base64、高熵;只登记不判恶 |
62
64
  | `DSC-20` 提示词注入 | **advisory** | 外部文本进 systemPrompt、指令样字面量、无 untrusted 包裹;语义不可判 |
63
- | `DSC-21` 供应链 | **blocking(强制项)/ WARN(发现封顶)** | scope 同形字/近似 scope、近似名、元数据缺失;**永不 FAIL**,但未跑会使 security `incomplete` |
65
+ | `DSC-21` 供应链 | **WARN-only(强制项,不阻断)** | scope 同形字/近似 scope、近似名、元数据缺失;**永不 FAIL**,listing/package 均只 WARN;未跑会使 security `incomplete` |
64
66
  | `DSC-22` 来源/完整性 | **advisory**(永不 FAIL) | 只读 `dist.integrity` / `dsh.attestation` / `dsh.signature` 字段是否存在;不验签、不比对 digest;无输入即 `not-run` |
65
67
 
66
68
  **默认不改变判定**:不传 `--scope security` 时,这 8 项不进 `checks[]`,只在 `security` 块里以 `scope=advisory` 运行;任何 FAIL 封顶 WARN,`DS-C` 的 11/12 条强制集合与 exit code 不变。security 的 FAIL 不能撤销 `DS-C` PASS,`DS-C` PASS 也不能证明 security 通过。
67
69
 
68
- **边界**:C-15/C-16/C-17 可以 FAIL;C-21 是强制项但发现只到 WARN;C-18/C-19/C-20/C-22 是 advisory;其余安全面(实际网络流量、审批绕过、同进程逃逸、client DOM、`!!js` 配置、Remote 调用方身份)**没有机械判据**。任何 PASS 都只是"本次覆盖内未见该类线索",不是"安全/已审计"。
70
+ **报告读法(headline vs buckets)**:`conclusion.headline` 的 `blocking N` 与 `buckets.blocking[]` 只数 `checks[]` 里的 FAIL。默认 scope 下 security FAIL 封顶 WARN,只在 `security` 块(`security.warn[]`,`security.blocking=[]`),**不进 `buckets.blocking`**;`--scope security` 下 `DSC-15/16/17` FAIL 才进 `buckets.blocking`、抬高 headline 并 exit 1。`DSC-21` 永不 FAIL,两个 profile 都不阻断。
71
+
72
+ **partial 怎么读**:`checks[].coverage=partial` 仍计入 `coverage.checked`(若结果 PASS/WARN/FAIL),并单列到 `coverage.partial` / `buckets.partial`;它**不并入** `coverage.notRun`(后者只数完整 `not-run` + `ERROR` + 缺失强制项)。`security` 块没有独立 `partial[]`:看 `security.checks[].coverage`;`security.notRun[]` 不收 partial。
73
+
74
+ **边界**:C-15/C-16/C-17 可以 FAIL;C-21 是强制项但 **WARN-only、不阻断(listing 同)**;C-18/C-19/C-20/C-22 是 advisory;其余安全面(实际网络流量、审批绕过、同进程逃逸、client DOM、`!!js` 配置、Remote 调用方身份)**没有机械判据**。任何 PASS 都只是"本次覆盖内未见该类线索",不是"安全/已审计"。
69
75
 
70
76
  ## 执行 opt-in
71
77
 
@@ -95,10 +101,14 @@ dsh-standards check <package-dir> \
95
101
  - **WSL2(Ubuntu 24.04):** Linux 沙箱可用,能提供 `unshare` / `prlimit`;这是本仓做 runtime 采集的环境。WSL2 可用不等于某个 host × Node × checker 组合已经跑过,具体以 [SUPPORTED.md](../SUPPORTED.md) 的格子为准。
96
102
  - **Node:** 包声明 `engines.node >=24.9.0`;Node 22 只在自测 / 静态矩阵中有记录(见 SUPPORTED.md)。
97
103
 
104
+ ## advice 的 docsUrl
105
+
106
+ 报告 `advice[].docsUrl` 的基址是 checker 的 `DOCS_BASE`:1.6.1 为 `dsh-plugin-guide` `v0.4.6`,计划在 **1.6.2 升到 `v0.5.0`**。guide 0.5.0 的 `docs/security.md` 中 C-15~C-22 advice 使用的锚点(`#2-credentials-and-sensitive-files-dsc-17`、`#3-egress-endpoints-dsc-18`、`#4-files-subprocesses-and-dynamic-execution-dsc-15-dsc-16-dsc-17-dsc-19`、`#5-tool-output-and-prompt-injection-dsc-20`、`#6-dependencies-build-and-publish-dsc-15-dsc-21-dsc-22`)以及显式锚点 `#author-files`、`#author-credentials`、`#author-egress`、`#author-prompt`、`#author-publish`、`#scope` 已核对存在。
107
+
98
108
  ## 复核命令
99
109
 
100
110
  ```sh
101
- # 默认:6/14 评估,8 项 not-run/NA;security 块是 advisory
111
+ # 默认静态:plugin 6/14(04–09 not-run),bundle 12/14(04–09 PASS+partial);security 块是 advisory
102
112
  dsh-standards check <package-dir> --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s);console.log(r.coverage.checked+"/"+r.coverage.total);console.log(r.anchor);console.log(r.security)})'
103
113
 
104
114
  # 默认 + security scope:checks[] 变 22 项(不执行插件)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-standards",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "DSH plugin standards and conformance checks: ENTRY/FLOW checks plus BOOST guidance. Non-official; no certification or ranking.",
5
5
  "type": "module",
6
6
  "main": "40-checker/src/index.mjs",
@@ -1,6 +1,6 @@
1
1
  # coverage 契约(`dsh-conformance/1`)
2
2
 
3
- > 适用版本:dsh-standards **1.6.0**(计划版本;1.5.0 及更早为 14 项)。`40-checker/src/report.mjs` 的 `buildReport()` 负责计数;`security` 块见 §6。
3
+ > 适用版本:dsh-standards **1.6.0+**(当前 1.6.1;1.5.0 及更早为 14 项)。`40-checker/src/report.mjs` 的 `buildReport()` 负责计数;`security` 块见 §6。
4
4
  > 机器可校验形状见 [`schemas/conformance.schema.json`](schemas/conformance.schema.json);本文只统一字段含义。
5
5
  > 结论:`coverage` 是**加性计数**,不参与 verdict;`PASS/WARN/FAIL` 与 `status/level` 由 `conclusion` 和对应 profile 的强制项决定。
6
6
 
@@ -25,14 +25,14 @@
25
25
  | `advisory` | `check.advisory === true` 且结果为 `WARN` 的数量;是 `warn` 的子集,只作信号,永不改 verdict。 |
26
26
  | `fail` | 结果 `FAIL` 的数量。 |
27
27
  | `na` | 结果 `NA` 的数量(对象形态/上下文不适用),**不是通过**。 |
28
- | `notRun` | `coverage` 为 `not-run`/`partial` 且结果非 `NA` 的项、结果 `ERROR` 的项、以及**缺失的强制项** ID 去重后的数量;包含 `error`。 |
28
+ | `notRun` | `coverage` 为 `not-run` 且结果非 `NA` 的项、结果 `ERROR` 的项、以及**缺失的强制项** ID 去重后的数量;包含 `error`。**不含** `partial`。 |
29
29
  | `error` | 结果 `ERROR` 的数量(检查器失效或该跑没跑);是 `notRun` 的子集。 |
30
30
 
31
31
  加性诊断字段(1.3.0 工作树同时输出;旧消费者应按“可选”处理,不参与 verdict):
32
32
 
33
33
  | 字段 | 定义 |
34
34
  |---|---|
35
- | `coverage.partial` | `checks[].coverage === "partial"` 的数量。它同时保留在 `checked`(若结果 PASS/WARN/FAIL)和 `notRun` 中;这个计数只是把重叠显式化,**不是第 10 个通过口径**。 |
35
+ | `coverage.partial` | `checks[].coverage === "partial"` 的数量。结果 PASS/WARN/FAIL 时仍计入 `checked`;但**不并入** `coverage.notRun`,只在 `coverage.partial` 与 `buckets.partial` 中单列。这个计数**不是第 10 个通过口径**。 |
36
36
  | 顶层 `notChecked` | `buckets.notChecked` 与 `buckets.partial` 按 `id` 去重后的可读视图,每行带 `howToEnable`。等价信息已在 `buckets` 里子集化,消费者可忽略。 |
37
37
  | `buckets.partial` | `coverage: "partial"` 的检查行;同一行可能也出现在 `blocking` / `hygiene`。 |
38
38
  | 行级 `howToEnable` | 让该 NA/ERROR/partial 行可运行的命令或 flag;仅提示。 |
@@ -42,7 +42,8 @@
42
42
  - `checked = pass + warn + fail`(`advisory <= warn`)。
43
43
  - `error <= notRun`;`notRun` 统计的是“没拿到完整结论”的项,`NA` 不算没跑。
44
44
  - 默认 `ds-c` 完整跑(14 项、无缺失)与 `--scope security` 完整跑(22 项、无缺失)都满足 `checked + na + notRun = total`;两份报告的 `total` 不同,不能混算通过率。
45
- - **例外**:若某项 `coverage: "partial"` 但结果仍是 `WARN`/`FAIL`,它会同时出现在 `checked` 与 `notRun` 中——已评估但不完整,不能当成 `full`。有缺失强制项时,`notRun` 会含不在 `checks[]` 里的 ID,此时上述加法可大于 `total`。
45
+ - **例外**:`coverage: "partial"` 的项若结果仍是 `WARN`/`FAIL`,它同时出现在 `checked` 与 `coverage.partial`/`buckets.partial`;它是"已评估但不完整",不并入 `notRun`。有缺失强制项时,`notRun` 会含不在 `checks[]` 里的 ID,此时加法可大于 `total`。
46
+ - **注**:`schemas/conformance.schema.json` 里 `coverage.notRun` 的旧描述仍写“含 partial”,与本表不符;以 `buildReport()` 当前输出(不含 partial)为准,schema 文案待后续版本同步。
46
47
  - `metrics = { checked, total, declaredFields }`:`checked`/`total` 与 `coverage` 同源;`declaredFields` 是 capabilities/labels/commitments/disclosure/engines.dsh 五个可选声明面中实际声明的个数(0–5),只作上下文。
47
48
 
48
49
  ## 3. 与旧检查级 `full` / `partial` 的映射
@@ -53,7 +54,7 @@
53
54
  |---|---|---|
54
55
  | `full` + `PASS`/`WARN`/`FAIL` | `checked`,并落入 `pass`/`warn`/`fail` | 完整评估,计入结论。 |
55
56
  | `full` + `NA` | `na` | 显式不适用;旧报告常把 NA 记在 `full` 之外。 |
56
- | `partial`(无论结果) | `notRun` | 只跑了一部分,必须当未完成;若结果仍是 `WARN`/`FAIL`,同时计入 `checked`。 |
57
+ | `partial`(无论结果) | `checked`(若结果 PASS/WARN/FAIL)+ `coverage.partial` / `buckets.partial` | 只跑了一部分:已评估但不完整;**不计入** `coverage.notRun`,不得当 `full`。 |
57
58
  | `not-run` | `notRun` | 完全没跑。 |
58
59
  | `ERROR`(旧 `error` 计数) | `notRun` + `error` | 检查器失效/未执行;两者都计。 |
59
60
 
@@ -61,7 +62,8 @@
61
62
 
62
63
  - 旧 `mandatory` → 新 `mandatory`(同义,选出的强制项数)。
63
64
  - 旧 `full` → 新 `checked` **减去** `partial` 但已评估的项,再减去 `full + NA` 的项;最稳妥的读法是“旧 full 只看覆盖,新 checked 看结果”。
64
- - 旧 `partial` / `notRun` → 新 `notRun`。
65
+ - 旧 `partial` → 新 `coverage.partial` / `buckets.partial`(结果 PASS/WARN/FAIL 时同时在 `checked`;不进 `notRun`)。
66
+ - 旧 `notRun` → 新 `notRun`。
65
67
  - 旧 `na` → 新 `na`。
66
68
  - 旧 `error` → 新 `error`(并已包含在 `notRun`)。
67
69
  - 旧对象没有 `total` / `pass` / `warn` / `advisory` / `fail` / `checked`;这些是 1.3.0 的新增计数。
@@ -108,10 +110,11 @@
108
110
  | `security.status=fail` | **仅 security scope**:`DSC-15`/`DSC-16`/`DSC-17` 任一 FAIL。`DSC-21` 永不 FAIL;默认模式 FAIL 封顶 WARN,不会出现 fail。 |
109
111
  | `security.status=warn` | 无 FAIL,但有 `DSC-21` 或 15/16/17 的 WARN,或 18/19/20/22 的 advisory WARN。 |
110
112
  | `security.status=incomplete` | **security scope 下** `DSC-15`/`DSC-16`/`DSC-17`/`DSC-21` 任一结果不在 `PASS`/`WARN`/`FAIL`(即 `NA`/`ERROR`/`not-run`)。 |
113
+ | `security` 块的 `partial` | 1.6.1 **没有** `security.partial[]`:部分覆盖看 `security.checks[].coverage === "partial"`;`security.notRun[]` 只收 `NA`/`ERROR`/完整 `not-run`,**不收 partial**。`security.status=pass` 不要求 partial 为空,市场必须读 `checks[]`,不能只看四个数组。顶层 `coverage.partial` / `buckets.partial` 在 security scope 下统计 `checks[]` 全部 22 项(含这 8 项)。 |
111
114
  | 与 DS-C 关系 | **独立**:security FAIL 不撤销 DS-C PASS;DS-C PASS 不证明 security 通过;反之亦然。 |
112
115
  | 徽章 | **不得**发 security 徽章;市场只透传 `security.status`、`security.checks[]` 与 `security.notRun[]`,不按安全分排序/筛选。 |
113
116
 
114
- 严重度以 `spec/dsc-clauses.json` 的 `mandatory`/`advisory` 与 31 §2 C-15~C-22 为准:`DSC-15/16/17` 为 blocking(可 FAIL);`DSC-21` 是强制项但发现封顶 WARN(永不 FAIL,未跑才 `incomplete`);`DSC-18/19/20/22` 为 advisory(`advisory:true`,永不改 verdict)。`security` 块只汇总这 8 项,不与顶层 `coverage` 换算成第二套通过率。
117
+ 严重度以 `spec/dsc-clauses.json` 的 `mandatory`/`advisory` 与 31 §2 C-15~C-22 为准:`DSC-15/16/17` 为 blocking(可 FAIL);`DSC-21` 是强制项但 **WARN-only、不阻断(listing 同;永不 FAIL,未跑才 `incomplete`)**;`DSC-18/19/20/22` 为 advisory(`advisory:true`,永不改 verdict)。`security` 块只汇总这 8 项,不与顶层 `coverage` 换算成第二套通过率。
115
118
 
116
119
  ## 7. 禁止事项
117
120