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.
- package/40-checker/fixtures/security/sec11-cred-declared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec11-cred-declared/package.json +21 -0
- package/40-checker/fixtures/security/sec11-cred-list/index.mjs +3 -0
- package/40-checker/fixtures/security/sec11-cred-list/package.json +21 -0
- package/40-checker/fixtures/security/sec11-cred-undeclared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec11-cred-undeclared/package.json +19 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/index.js +2 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/scripts/install.js +11 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/scripts/local-util.cjs +1 -0
- package/40-checker/fixtures/security/sec15-install-dynamic/index.js +2 -0
- package/40-checker/fixtures/security/sec15-install-dynamic/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-dynamic/scripts/install.js +11 -0
- package/40-checker/fixtures/security/sec15-install-remote-literal/index.js +1 -0
- package/40-checker/fixtures/security/sec15-install-remote-literal/package.json +8 -0
- package/40-checker/fixtures/security/sec15-install-remote-literal/scripts/install.js +6 -0
- package/40-checker/fixtures/security/sec16-base64-clean/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-base64-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec16-base64-eval/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-base64-eval/package.json +19 -0
- package/40-checker/fixtures/security/sec16-exec-curl/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-exec-curl/package.json +19 -0
- package/40-checker/fixtures/security/sec16-import-only/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-import-only/package.json +19 -0
- package/40-checker/fixtures/security/sec16-spawn-shell/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-spawn-shell/package.json +19 -0
- package/40-checker/fixtures/security/sec16-subprocess-safe/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-subprocess-safe/package.json +19 -0
- package/40-checker/fixtures/security/sec17-alias-clean/data.txt +1 -0
- package/40-checker/fixtures/security/sec17-alias-clean/index.mjs +5 -0
- package/40-checker/fixtures/security/sec17-alias-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec17-alias-read/index.mjs +7 -0
- package/40-checker/fixtures/security/sec17-alias-read/package.json +19 -0
- package/40-checker/fixtures/security/sec17-base64-path/index.mjs +11 -0
- package/40-checker/fixtures/security/sec17-base64-path/package.json +19 -0
- package/40-checker/fixtures/security/sec17-base64-path-clean/data/config.json +1 -0
- package/40-checker/fixtures/security/sec17-base64-path-clean/index.mjs +5 -0
- package/40-checker/fixtures/security/sec17-base64-path-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec17-home-env/index.js +8 -0
- package/40-checker/fixtures/security/sec17-home-env/package.json +19 -0
- package/40-checker/fixtures/security/sec17-home-env-clean/index.mjs +6 -0
- package/40-checker/fixtures/security/sec17-home-env-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec18-split-fetch/index.mjs +5 -0
- package/40-checker/fixtures/security/sec18-split-fetch/package.json +19 -0
- package/40-checker/fixtures/security/sec18-split-fetch-clean/index.mjs +4 -0
- package/40-checker/fixtures/security/sec18-split-fetch-clean/package.json +24 -0
- package/40-checker/src/declarations.mjs +6 -1
- package/40-checker/src/remediation.mjs +1 -1
- package/40-checker/src/security.mjs +258 -22
- package/CHANGELOG.md +16 -0
- package/docs/coverage.md +17 -7
- package/package.json +1 -1
- 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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
704
|
-
if (!
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
763
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
|
19
|
-
|
|
|
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 | **
|
|
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` 供应链 | **
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
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"`
|
|
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
|
-
-
|
|
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`(无论结果) | `
|
|
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`
|
|
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`
|
|
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
|
|