dsh-standards 1.4.1 → 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 (69) 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/scripts/sign-evidence.mjs +169 -0
  51. package/40-checker/src/cli.mjs +402 -73
  52. package/40-checker/src/declarations.mjs +10 -10
  53. package/40-checker/src/flow-evidence.mjs +21 -0
  54. package/40-checker/src/index.mjs +2 -1
  55. package/40-checker/src/report.mjs +116 -4
  56. package/40-checker/src/runtime-child.mjs +68 -0
  57. package/40-checker/src/runtime.mjs +3 -1
  58. package/40-checker/src/sandbox-run.mjs +406 -35
  59. package/40-checker/src/security.mjs +1065 -0
  60. package/40-checker/src/signature.mjs +249 -0
  61. package/40-checker/src/util.mjs +53 -0
  62. package/CHANGELOG.md +34 -0
  63. package/docs/SIGNING.md +137 -0
  64. package/docs/coverage.md +39 -6
  65. package/package.json +1 -1
  66. package/spec/coverage-contract.md +33 -11
  67. package/spec/dsc-clauses.json +410 -9
  68. package/spec/remediation.json +196 -0
  69. package/spec/schemas/conformance.schema.json +80 -2
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Ed25519 signatures for FLOW evidence (`dsh-flow-evidence/2`).
3
+ *
4
+ * Zero dependencies: `node:crypto` only. A signature is a detached Ed25519
5
+ * signature over the canonical JSON encoding of the document with the
6
+ * `signature` field removed. The chain hash is computed over the body (minus
7
+ * `chain` and `signature`); the signed payload includes `chain`, so the
8
+ * signature also binds the declared chain hash. Signing never changes
9
+ * `chain.evidenceHash`, because `signature` is excluded from both payloads.
10
+ *
11
+ * Signature record:
12
+ *
13
+ * signature: {
14
+ * alg: "ed25519",
15
+ * keyId: "<first 16 hex of sha256(SPKI public key PEM)>",
16
+ * publicKey: "<SPKI PEM>",
17
+ * sig: "<base64 detached signature>",
18
+ * at: "<ISO-8601 timestamp>"
19
+ * }
20
+ *
21
+ * `keyId` is derived from the exact SPKI PEM string that is embedded as
22
+ * `publicKey` (line endings included), so a verifier can recompute it from the
23
+ * signature record. This is an integrity/origin mechanism only: an embedded
24
+ * public key is self-asserted, so a valid signature does not make the signer
25
+ * trustworthy.
26
+ */
27
+ import crypto from 'node:crypto'
28
+
29
+ export const SIGNATURE_ALG = 'ed25519'
30
+ export const SIGNATURE_SCHEMA = 'dsh-evidence-signature/1'
31
+ export const KEY_ID_LENGTH = 16
32
+
33
+ function isPlainObject(value) {
34
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
35
+ }
36
+
37
+ /** JSON with recursively sorted object keys; array order is preserved. */
38
+ export function canonicalJson(value) {
39
+ const sort = (node) => {
40
+ if (Array.isArray(node)) return node.map(sort)
41
+ if (isPlainObject(node)) {
42
+ const out = {}
43
+ for (const key of Object.keys(node).sort()) out[key] = sort(node[key])
44
+ return out
45
+ }
46
+ return node
47
+ }
48
+ return JSON.stringify(sort(value))
49
+ }
50
+
51
+ /**
52
+ * The exact bytes an evidence signature covers: canonical JSON of every field
53
+ * except `signature`. Unlike `evidenceHash()` (which also removes `chain`),
54
+ * this includes `chain`, so a signature binds the declared chain hash as well.
55
+ */
56
+ export function evidenceSigningPayload(document) {
57
+ if (!isPlainObject(document)) throw new TypeError('evidence document must be a JSON object')
58
+ const copy = { ...document }
59
+ delete copy.signature
60
+ return Buffer.from(canonicalJson(copy), 'utf8')
61
+ }
62
+
63
+ function asPublicKey(key) {
64
+ if (key instanceof crypto.KeyObject) {
65
+ if (key.type === 'public') return key
66
+ return crypto.createPublicKey(key)
67
+ }
68
+ return crypto.createPublicKey(key)
69
+ }
70
+
71
+ function asPrivateKey(key) {
72
+ if (key instanceof crypto.KeyObject) {
73
+ if (key.type === 'private') return key
74
+ return crypto.createPrivateKey(key)
75
+ }
76
+ return crypto.createPrivateKey(key)
77
+ }
78
+
79
+ export function publicKeyPem(key) {
80
+ return asPublicKey(key).export({ type: 'spki', format: 'pem' }).toString()
81
+ }
82
+
83
+ /** keyId = first 16 hex of sha256(SPKI public key PEM text). */
84
+ export function keyIdFromPublicKey(key) {
85
+ let pem
86
+ if (typeof key === 'string' && key.includes('BEGIN PUBLIC KEY')) {
87
+ pem = key
88
+ } else if (Buffer.isBuffer(key) && key.toString('utf8').includes('BEGIN PUBLIC KEY')) {
89
+ pem = key.toString('utf8')
90
+ } else {
91
+ pem = publicKeyPem(key)
92
+ }
93
+ return crypto.createHash('sha256').update(pem).digest('hex').slice(0, KEY_ID_LENGTH)
94
+ }
95
+
96
+ export function generateEd25519KeyPair() {
97
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
98
+ return {
99
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
100
+ publicKeyPem: publicKeyPem(publicKey),
101
+ keyId: keyIdFromPublicKey(publicKey),
102
+ }
103
+ }
104
+
105
+ export function loadPrivateKey(pem) {
106
+ return crypto.createPrivateKey(pem)
107
+ }
108
+
109
+ export function loadPublicKey(pem) {
110
+ return crypto.createPublicKey(pem)
111
+ }
112
+
113
+ /**
114
+ * Return a copy of `document` carrying an Ed25519 signature. Any previous
115
+ * signature is replaced (it is not part of the signed payload). `at` defaults
116
+ * to now; pass an explicit ISO timestamp for reproducible evidence.
117
+ */
118
+ export function signEvidence(document, privateKey, { at = new Date().toISOString(), keyId = null } = {}) {
119
+ if (!isPlainObject(document)) throw new TypeError('evidence document must be a JSON object')
120
+ const privateObject = asPrivateKey(privateKey)
121
+ const publicObject = crypto.createPublicKey(privateObject)
122
+ const pem = publicKeyPem(publicObject)
123
+ const computed = keyIdFromPublicKey(pem)
124
+ const id = keyId ?? computed
125
+ if (typeof id !== 'string' || !/^[0-9a-f]{16}$/.test(id)) {
126
+ throw new Error('signature.keyId must be 16 lowercase hex characters')
127
+ }
128
+ if (id !== computed) {
129
+ throw new Error(`signature.keyId does not match the private key (expected ${computed})`)
130
+ }
131
+ if (typeof at !== 'string' || at.length === 0) {
132
+ throw new Error('signature.at must be a non-empty ISO-8601 string')
133
+ }
134
+ const sig = crypto.sign(null, evidenceSigningPayload(document), privateObject).toString('base64')
135
+ return {
136
+ ...document,
137
+ signature: {
138
+ alg: SIGNATURE_ALG,
139
+ keyId: id,
140
+ publicKey: pem,
141
+ sig,
142
+ at,
143
+ },
144
+ }
145
+ }
146
+
147
+ function invalid(reason, extra = {}) {
148
+ return { status: 'invalid', reason, ...extra }
149
+ }
150
+
151
+ /**
152
+ * Verify a signed evidence document.
153
+ *
154
+ * - `missing`: no `signature` (or `signature: null`), or no public key is
155
+ * available to check it (embedded key absent and no pinned key supplied).
156
+ * - `invalid`: malformed signature, key mismatch, or a payload that does not
157
+ * verify.
158
+ * - `valid`: the signature verifies against the embedded key (or `publicKey`
159
+ * when pinned).
160
+ *
161
+ * `publicKey` pins the trust anchor: pass a PEM/KeyObject out-of-band and the
162
+ * embedded key is only used to check `keyId` consistency. Without a pin, the
163
+ * embedded key is self-asserted — valid means "these bytes were signed by the
164
+ * holder of this key", not "the signer is trusted".
165
+ */
166
+ export function verifyEvidenceSignature(document, { publicKey = null } = {}) {
167
+ if (!isPlainObject(document)) return invalid('evidence document must be a JSON object')
168
+ const signature = document.signature
169
+ if (signature === undefined || signature === null) {
170
+ return { status: 'missing', reason: 'document has no signature' }
171
+ }
172
+ if (!isPlainObject(signature)) return invalid('signature must be an object')
173
+ if (signature.alg !== SIGNATURE_ALG) return invalid(`signature.alg must be ${SIGNATURE_ALG}`)
174
+ if (typeof signature.sig !== 'string' || signature.sig.length === 0) {
175
+ return invalid('signature.sig must be a non-empty base64 string')
176
+ }
177
+ if (typeof signature.keyId !== 'string' || !/^[0-9a-f]{16}$/.test(signature.keyId)) {
178
+ return invalid('signature.keyId must be 16 lowercase hex characters')
179
+ }
180
+
181
+ let embedded = null
182
+ if (signature.publicKey !== undefined && signature.publicKey !== null) {
183
+ if (typeof signature.publicKey !== 'string' || signature.publicKey.trim() === '') {
184
+ return invalid('signature.publicKey must be a non-empty PEM string when present')
185
+ }
186
+ try {
187
+ embedded = asPublicKey(signature.publicKey)
188
+ } catch {
189
+ return invalid('signature.publicKey is not a valid public key')
190
+ }
191
+ const embeddedId = keyIdFromPublicKey(signature.publicKey)
192
+ if (embeddedId !== signature.keyId) {
193
+ return invalid('signature.keyId does not match sha256(signature.publicKey)[:16]', {
194
+ keyId: embeddedId,
195
+ embeddedKeyId: embeddedId,
196
+ keyIdMatch: false,
197
+ })
198
+ }
199
+ }
200
+
201
+ let key = null
202
+ let source = null
203
+ if (publicKey !== null && publicKey !== undefined) {
204
+ try {
205
+ key = asPublicKey(publicKey)
206
+ } catch {
207
+ return invalid('pinned public key is not a valid public key')
208
+ }
209
+ source = 'pinned'
210
+ const pinnedId = keyIdFromPublicKey(key)
211
+ if (pinnedId !== signature.keyId) {
212
+ return invalid('signature.keyId does not match the pinned public key', {
213
+ keyId: pinnedId,
214
+ embeddedKeyId: embedded ? keyIdFromPublicKey(embedded) : null,
215
+ keyIdMatch: false,
216
+ })
217
+ }
218
+ } else if (embedded) {
219
+ key = embedded
220
+ source = 'embedded'
221
+ } else {
222
+ return {
223
+ status: 'missing',
224
+ reason: 'no public key: signature.publicKey is missing and no pinned public key was supplied',
225
+ }
226
+ }
227
+
228
+ const derivedKeyId = keyIdFromPublicKey(key)
229
+ let ok = false
230
+ try {
231
+ ok = crypto.verify(null, evidenceSigningPayload(document), key, Buffer.from(signature.sig, 'base64'))
232
+ } catch (error) {
233
+ return invalid(`signature verification failed: ${error.message}`, {
234
+ keyId: derivedKeyId,
235
+ keyIdMatch: true,
236
+ })
237
+ }
238
+ const result = {
239
+ status: ok ? 'valid' : 'invalid',
240
+ alg: SIGNATURE_ALG,
241
+ keyId: derivedKeyId,
242
+ embeddedKeyId: embedded ? keyIdFromPublicKey(embedded) : null,
243
+ keyIdMatch: true,
244
+ publicKeySource: source,
245
+ at: typeof signature.at === 'string' ? signature.at : null,
246
+ }
247
+ if (!ok) result.reason = 'signature does not verify against the public key'
248
+ return result
249
+ }
@@ -123,6 +123,59 @@ export function listSourceFiles(root, options = {}) {
123
123
  return scanSourceFiles(root, options).paths
124
124
  }
125
125
 
126
+ /**
127
+ * Inventory every file in a package (not only source extensions) without
128
+ * following symlinks. Used by the security checks for binary/obfuscation and
129
+ * script-reference analysis. Same skip directories as the source walker so
130
+ * node_modules/tests/docs do not dominate the sample.
131
+ * @param {string} root
132
+ * @param {{maxFiles?: number, maxBytes?: number}} [options]
133
+ */
134
+ export function inventoryFiles(root, options = {}) {
135
+ const maxFiles = options.maxFiles ?? 1000
136
+ const maxBytes = options.maxBytes ?? 20_000_000
137
+ const files = []
138
+ let bytes = 0
139
+ let truncated = false
140
+ const walk = (directory) => {
141
+ if (files.length >= maxFiles || bytes >= maxBytes) {
142
+ truncated = true
143
+ return
144
+ }
145
+ let entries
146
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { return }
147
+ entries.sort((a, b) => a.name.localeCompare(b.name))
148
+ for (const entry of entries) {
149
+ if (files.length >= maxFiles || bytes >= maxBytes) {
150
+ truncated = true
151
+ return
152
+ }
153
+ const absolute = path.join(directory, entry.name)
154
+ if (entry.isDirectory()) {
155
+ if (SKIP_DIRECTORIES.has(entry.name)) continue
156
+ if (entry.name.startsWith('.')) continue
157
+ walk(absolute)
158
+ continue
159
+ }
160
+ if (!entry.isFile()) continue
161
+ let size = 0
162
+ try { size = fs.statSync(absolute).size } catch { continue }
163
+ if (bytes + size > maxBytes) {
164
+ truncated = true
165
+ continue
166
+ }
167
+ bytes += size
168
+ files.push({
169
+ file: absolute,
170
+ relative: posixRelative(root, absolute),
171
+ size,
172
+ })
173
+ }
174
+ }
175
+ walk(root)
176
+ return { files, stats: { files: files.length, bytes, maxFiles, maxBytes, truncated } }
177
+ }
178
+
126
179
  /** Read all source files once, returning {file, relative, text} records. */
127
180
  export function readSources(root, options) {
128
181
  return scanSourceFiles(root, options).paths.map(file => ({
package/CHANGELOG.md CHANGED
@@ -1,3 +1,37 @@
1
+ ## 1.6.0 — 2026-11-07
2
+
3
+ ### Added
4
+
5
+ - 安全层 DSC-15…22:生命周期脚本、动态执行、敏感文件、外联端点、混淆/二进制、提示词注入线索、供应链、来源/完整性。
6
+ - `--scope security`:22 项检查,DSC-15/16/17/21 为强制;默认 14 项判定不变,security 块内 FAIL 封顶 WARN。
7
+ - 威胁模型与宿主边界(`34` §十):同进程全权限、capabilities 不被宿主读取、sandbox 仅子进程文件写、approval 仅 tool ask、`!!js` 可执行、client 可调 Remote。
8
+ - `SECURITY.md`(漏洞报告)、`spec/dsc-clauses.json` 22 父/113 条、`spec/remediation.json` C-15…22、coverage 口径 22。
9
+
10
+ ### Verified
11
+
12
+ - `npm test` 269/269;恶意 fixture(curl|sh、eval、凭据读取、typosquat)在 security scope 下 FAIL;10 个真实包默认 entry 10/10 exit 0。
13
+ - 静态安全 PASS 不代表无恶意;检查器不能证明安全。
14
+
15
+ ## 1.5.0 — 2026-11-07
16
+
17
+ ### Added
18
+
19
+ - 真实运行时检查:`--runtime`/`--host` 自动解析已装 dsh,在临时 HOME/DSH_HOME + env 白名单 + 超时硬杀下跑 DSC-04–09;报告新增 `runtimeChecks`(executed/modes/durationMs/isolation/checks)。
20
+ - Ed25519 证据签名:`sign-evidence.mjs keygen|sign|verify`(node:crypto,零依赖),`docs/SIGNING.md` 说明密钥与信任边界。
21
+ - 沙箱加固:`setpriv --no-new-privs --bounding-set=-all`、`nofile/fsize` rlimit、`unshare -Urm` + 只读 bind,记录 `hardening[]/hardeningDegraded[]`。
22
+
23
+ ### Fixed
24
+
25
+ - `incomplete` 只由「mandatory 且 not-run」触发;静态 `partial` 不再把 ds-c bundle 判成 incomplete(修复前 dsh-fast 静态 ds-c 为 incomplete/exit 2,修复后 pass/exit 0)。
26
+ - runtime-child 写 fd3 后立即退出,不再被插件 timers 拖到超时导致六项全 not-run。
27
+ - 裸 `--runtime` 对带入口 bundle 直接走 server-entry;无入口纯 patch bundle 仍需 `--host`。
28
+ - `conformance.schema.json` 加 `runtimeChecks`。
29
+
30
+ ### Verified
31
+
32
+ - `npm test` 249/249;真实宿主 dsh 0.2.1-rc.5:dsh-fast/mcp-panel 的 DSC-04–09 出 P/NA/P/P/W/P,security-pack 20.9s 完成 G0–G3。
33
+ - Windows 11 + node 24.22.1 实测:check 结果与 WSL 逐字一致;sandbox 记 degraded(unshare/prlimit 缺失),`flow collect --sandbox` 因 `npm.cmd` 无法无 shell spawn 记 not-run。
34
+
1
35
  ## 1.4.1 — 2026-11-06
2
36
 
3
37
  - 建议的 `docsUrl` 基址跟到 `dsh-plugin-guide@v0.4.6`(FLOW 示例修正版),不再指向 0.4.4。
@@ -0,0 +1,137 @@
1
+ # Evidence signing (Ed25519)
2
+
3
+ `dsh-flow-evidence/2` documents can carry a detached Ed25519 signature. The
4
+ implementation uses only `node:crypto` (no dependencies) and lives in
5
+ `40-checker/src/signature.mjs`; `40-checker/scripts/sign-evidence.mjs` is the
6
+ CLI. Signing is optional: it does not change `validateFlowEvidence()`, does not
7
+ change the chain hash, and never upgrades a claim level.
8
+
9
+ ## What is signed
10
+
11
+ The payload is canonical JSON (recursively sorted object keys, array order
12
+ preserved) of every field **except `signature`**. This includes `chain`, so a
13
+ signature also binds the declared `chain.evidenceHash`. The chain hash itself
14
+ is computed over the body only (it excludes `chain` and `signature`). A signed
15
+ document keeps its existing `chain.evidenceHash`; `signature` is outside the
16
+ hash, so signing and re-signing do not invalidate the chain.
17
+
18
+ ```
19
+ signature: {
20
+ alg: "ed25519",
21
+ keyId: "<first 16 hex of sha256(SPKI public key PEM)>",
22
+ publicKey: "<SPKI PEM>",
23
+ sig: "<base64 detached signature>",
24
+ at: "<ISO-8601 timestamp>"
25
+ }
26
+ ```
27
+
28
+ `keyId` is the first 16 hex characters of sha256 over the exact SPKI PEM string
29
+ embedded as `publicKey` (line endings included), so a verifier can recompute it
30
+ from the signature record. `publicKey` is embedded for transport convenience;
31
+ it is **self-asserted**.
32
+
33
+ ## Key management
34
+
35
+ ```sh
36
+ # Generate once, outside the repository working tree.
37
+ node 40-checker/scripts/sign-evidence.mjs keygen --out ~/.dsh-evidence-keys
38
+ # ed25519.pem (PKCS#8 private) and ed25519.pub.pem (SPKI public), both mode 0600.
39
+ ```
40
+
41
+ - Never commit `ed25519.pem`, put it in logs, or paste it into an issue. Back it
42
+ up in a secret manager; losing it means old signatures can no longer be
43
+ produced (they remain verifiable).
44
+ - `keygen` refuses to overwrite an existing key unless `--force` is passed.
45
+ - Distribute `ed25519.pub.pem` and its `keyId` through a separate, trusted
46
+ channel (release notes, repository file, pinned fingerprint). A public key
47
+ fetched from the same JSON it verifies proves nothing about identity.
48
+ - Rotate by generating a new pair; signatures keep their old `keyId`. Verifiers
49
+ should accept a set of pinned keyIds and record which one matched.
50
+ - The `at` field is signer-defined. It is evidence metadata, not a trusted
51
+ timestamp; a signer can backdate or replay. Add policy (expiry, nonce,
52
+ transparency log) outside this tool if that matters.
53
+
54
+ ## Usage
55
+
56
+ ```sh
57
+ # Sign after the collector has written and finalized the evidence.
58
+ node 40-checker/scripts/sign-evidence.mjs sign flow-evidence.json \
59
+ --key ~/.dsh-evidence-keys/ed25519.pem \
60
+ --out flow-evidence.signed.json
61
+
62
+ # Verify. Prints exactly one of: valid | invalid | missing.
63
+ node 40-checker/scripts/sign-evidence.mjs verify flow-evidence.signed.json
64
+ node 40-checker/scripts/sign-evidence.mjs verify flow-evidence.signed.json \
65
+ --pub ~/.dsh-evidence-keys/ed25519.pub.pem
66
+ ```
67
+
68
+ Exit codes: `0` valid, `1` invalid or missing, `2` usage/input error.
69
+ `--json` prints the detailed result (`status`, `keyId`, `keyIdMatch`,
70
+ `publicKeySource`, `reason`). `missing` means the document has no signature (or
71
+ `signature: null`), or there is no public key to check it (no embedded
72
+ `publicKey` and no readable `--pub`). `invalid` covers malformed records, key
73
+ mismatch, and a payload that does not verify. When `--pub` is supplied, it is
74
+ the trust anchor: the embedded key is only used to cross-check `keyId`.
75
+
76
+ The same helpers are re-exported from `40-checker/src/flow-evidence.mjs`:
77
+ `attachEvidenceSignature()`, `verifyEvidenceSignature()`,
78
+ `evidenceSigningPayload()`, `keyIdFromPublicKey()`.
79
+
80
+ ## CI usage
81
+
82
+ Keep the private key in a CI secret and materialize it as a 0600 file only for
83
+ the signing step; delete it immediately afterwards. Never print it.
84
+
85
+ ```yaml
86
+ - name: Sign FLOW evidence
87
+ env:
88
+ EVIDENCE_PRIVATE_KEY: ${{ secrets.EVIDENCE_PRIVATE_KEY }}
89
+ run: |
90
+ umask 077
91
+ printf '%s' "$EVIDENCE_PRIVATE_KEY" > "$RUNNER_TEMP/evidence-key.pem"
92
+ node 40-checker/scripts/sign-evidence.mjs sign flow-evidence.json \
93
+ --key "$RUNNER_TEMP/evidence-key.pem" \
94
+ --out flow-evidence.signed.json
95
+ rm -f "$RUNNER_TEMP/evidence-key.pem"
96
+
97
+ - name: Verify with the pinned public key
98
+ run: |
99
+ node 40-checker/scripts/sign-evidence.mjs verify flow-evidence.signed.json \
100
+ --pub .github/evidence-pub.pem
101
+ ```
102
+
103
+ For PRs from forks, do not expose the signing secret to code from the fork.
104
+ Sign on a trusted workflow (e.g. `workflow_run` after review, or a maintainer
105
+ run); a signature produced by untrusted code is only as trustworthy as that
106
+ code.
107
+
108
+ ## Trust boundary
109
+
110
+ **A valid self-signature is not trust.** It proves that whoever held the
111
+ private key signed these exact bytes. It does **not** prove:
112
+
113
+ - who that holder is (no identity, no CA, no account binding);
114
+ - that the evidence was produced honestly, or that commands ran where claimed;
115
+ - that the subject package is safe, correct, or officially endorsed;
116
+ - freshness: `at` can be backdated and a valid old document can be replayed;
117
+ - that the signer is allowed to speak for a project or marketplace.
118
+
119
+ Consequences:
120
+
121
+ - A verifier must pin the public key or `keyId` out-of-band. Treat the embedded
122
+ `publicKey` as a hint, not as an identity.
123
+ - Compromise of the private key compromises every signature made with it;
124
+ revocation and rotation are policy decisions this tool does not implement.
125
+ - Signature + chain hash give integrity and origin-of-holding, not truth. The
126
+ structural rules of `validateFlowEvidence()` and `level-audit` are unchanged;
127
+ signing cannot turn `self-attested`/`ci-attested` into `sandbox-attested`.
128
+ - A marketplace or adoption decision still needs its own policy: who is
129
+ allowed to sign, which keyIds are accepted, what evidence is required, how
130
+ long it is valid, and how disputes/revocations are handled. This repository
131
+ ships a mechanism, not a trust root and not a certification.
132
+
133
+ For the sandbox side of the evidence, see `40-checker/src/sandbox-run.mjs`:
134
+ `setpriv --no-new-privs --bounding-set=-all`, extra `nofile`/`fsize` rlimits,
135
+ and a read-only bind of the package copy are attempted and recorded. They are
136
+ containment, not seccomp/cgroup/OCI isolation, and they do not make a
137
+ self-signed document trustworthy.
package/docs/coverage.md CHANGED
@@ -1,4 +1,4 @@
1
- # 覆盖说明(14 项)— `dsh-standards check` 默认查什么
1
+ # 覆盖说明(22 项口径)— `dsh-standards check` 默认查什么
2
2
 
3
3
  `dsh-standards check <package-dir>` 是作者入口。**默认 `--entry-claim static --no-exec`:不执行插件代码**。本文只描述这套默认,不把 `not-run` / `NA` 写成 PASS。
4
4
 
@@ -13,13 +13,15 @@ dsh-standards check <package-dir> \
13
13
 
14
14
  | 口径 | 数量 | 说明 |
15
15
  |---|---|---|
16
- | 报告固定携带 | **14** | `DSC-01` … `DSC-14` |
16
+ | 完整口径 | **22** | `DSC-01` … `DSC-22`;`--scope security` 时 `checks[]` 带全 22 项 |
17
+ | 默认 `checks[]` | **14** | `DSC-01` … `DSC-14`;默认 DS-C 判定只看这 14 项 |
17
18
  | 默认实际评估 | **6** | `DSC-01`、`DSC-02`、`DSC-10`、`DSC-11`、`DSC-12`、`DSC-13` |
18
19
  | 默认未检查 | **8** | `DSC-03`(NA)+ `DSC-04`–`DSC-09`(not-run)+ `DSC-14`(NA) |
20
+ | 默认 security 块 | **8** | `DSC-15`–`DSC-22` 在 `security` 块里跑 advisory;FAIL 封顶 WARN,不改变 DS-C 结论 |
19
21
 
20
22
  `not checked` 桶把 NA / not-run / ERROR 都列出来;**没检查不等于通过**。`DSC-10` 是 advisory,只提供信号,不改变结论。
21
23
 
22
- ## 14 项逐条
24
+ ## 22 项逐条
23
25
 
24
26
  | ID | 层 | 默认状态 | 默认为什么 | 怎么开启 |
25
27
  |---|---|---|---|---|
@@ -37,6 +39,33 @@ dsh-standards check <package-dir> \
37
39
  | `DSC-12` | L0 | **检查** | 凭据 backing file、`listRecords`、seam、commitments/disclosure 形状 | 默认即查 |
38
40
  | `DSC-13` | L0 | **检查** | `dsh.labels` / `upstream` / 禁用语标签 | 默认即查 |
39
41
  | `DSC-14` | L1 | **NA** | 默认 `entry-claim=static`,不声明运行时证据 | `--scope entry --entry-claim runtime --runtime <...>`(bundle 加 `--host`) |
42
+ | `DSC-15` | L0 | **advisory(security 块)** | 默认 scope 下 FAIL 封顶 WARN | `--scope security`;生命周期脚本,可 FAIL |
43
+ | `DSC-16` | L0 | **advisory(security 块)** | 默认 scope 下 FAIL 封顶 WARN | `--scope security`;动态执行,可 FAIL |
44
+ | `DSC-17` | L0 | **advisory(security 块)** | 默认 scope 下 FAIL 封顶 WARN | `--scope security`;敏感文件,可 FAIL |
45
+ | `DSC-18` | L0 | **advisory(security 块)** | 端点线索,永不 FAIL | `--scope security`;外联端点 |
46
+ | `DSC-19` | L0 | **advisory(security 块)** | 只登记不判恶 | `--scope security`;混淆/二进制 |
47
+ | `DSC-20` | L0 | **advisory(security 块)** | 语义不可判、误报高 | `--scope security`;提示词注入 |
48
+ | `DSC-21` | L0 | **advisory WARN(security 块)** | 名字线索;默认 scope 不 FAIL | `--scope security`;供应链,强制项但只 WARN |
49
+ | `DSC-22` | L0 | **advisory(security 块)** | 只有字段自证 | `--scope security`;来源/完整性,v1 只读字段 |
50
+
51
+ ## security scope(C-15~C-22)
52
+
53
+ `--scope security` 是**独立、opt-in** 的安全范围。它把 8 项加入 `checks[]`(14 → 22),但**不改变** `DS-C` 徽章、也不并入 DS-C 的 11/12 条强制。它只降低风险,**不证明无恶意**:
54
+
55
+ | 项 | security scope 判定 | 触发 |
56
+ |---|---|---|
57
+ | `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 |
59
+ | `DSC-17` 敏感文件 | **blocking**(可 FAIL) | 直接读 `~/.ssh`、`$DSH_HOME` credentials、`id_rsa`、`.aws`、`.npmrc`、`.netrc` 等;`.env` 只 WARN |
60
+ | `DSC-18` 外联端点 | **advisory**(永不 FAIL) | 非 https、userinfo、公网裸 IP、webhook/paste/ngrok/onion、未披露端点 |
61
+ | `DSC-19` 混淆/二进制 | **advisory** | 二进制 sha256、超长单行、大块 base64、高熵;只登记不判恶 |
62
+ | `DSC-20` 提示词注入 | **advisory** | 外部文本进 systemPrompt、指令样字面量、无 untrusted 包裹;语义不可判 |
63
+ | `DSC-21` 供应链 | **blocking(强制项)/ WARN(发现封顶)** | scope 同形字/近似 scope、近似名、元数据缺失;**永不 FAIL**,但未跑会使 security `incomplete` |
64
+ | `DSC-22` 来源/完整性 | **advisory**(永不 FAIL) | 只读 `dist.integrity` / `dsh.attestation` / `dsh.signature` 字段是否存在;不验签、不比对 digest;无输入即 `not-run` |
65
+
66
+ **默认不改变判定**:不传 `--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
+
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 都只是"本次覆盖内未见该类线索",不是"安全/已审计"。
40
69
 
41
70
  ## 执行 opt-in
42
71
 
@@ -44,7 +73,8 @@ dsh-standards check <package-dir> \
44
73
 
45
74
  - `--runtime <path>`:Cordis 运行时的 `lib/index.js`,开启 L1–L3;
46
75
  - `--host <file>`:app-boot 入口,开启 bundle 的 G0–G3 冒烟;
47
- - `--entry-claim runtime`:entry 门声明运行时证据(通常还要配 `--runtime` / `--host`)。
76
+ - `--entry-claim runtime`:entry 门声明运行时证据(通常还要配 `--runtime` / `--host`);
77
+ - `--scope security`:只跑 C-15~C-22 的静态/元数据检查,**仍不执行插件代码**(C-15 只读脚本文本,不跑 `npm install`)。
48
78
 
49
79
  想保留运行时声明但只看静态结果,显式加 `--no-exec`。`--runtime-version` 只是 anchor,**不是**执行开关。
50
80
 
@@ -68,8 +98,11 @@ dsh-standards check <package-dir> \
68
98
  ## 复核命令
69
99
 
70
100
  ```sh
71
- # 默认:6/14 评估,8 项 not-run/NA
72
- 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.safety)})'
101
+ # 默认:6/14 评估,8 项 not-run/NA;security 块是 advisory
102
+ 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
+
104
+ # 默认 + security scope:checks[] 变 22 项(不执行插件)
105
+ dsh-standards check <package-dir> --scope security --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.security)})'
73
106
 
74
107
  # 显式声明宿主版本,仍不执行插件
75
108
  dsh-standards check <package-dir> --host-version 0.2.2-rc.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-standards",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
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.3.0**(`40-checker/src/report.mjs` 的 `buildReport()`)。
3
+ > 适用版本:dsh-standards **1.6.0**(计划版本;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
 
@@ -17,8 +17,8 @@
17
17
 
18
18
  | 字段 | 定义 |
19
19
  |---|---|
20
- | `total` | `checks[].length`,完整跑是 14(`DSC-01`…`DSC-14`)。 |
21
- | `mandatory` | 当前 `scope`/`profile`/`staticOnly`/`entryClaim` 选出的强制检查 ID 数,不是 14。 |
20
+ | `total` | `checks[].length`:默认 `ds-c` 为 14(`DSC-01`…`DSC-14`);`--scope security` 为 22(再加 `DSC-15`…`DSC-22`)。 |
21
+ | `mandatory` | 当前 `scope`/`profile`/`staticOnly`/`entryClaim` 选出的强制检查 ID 数,不是 22;默认 package 11、listing 12、`--scope security` 4(`DSC-15/16/17/21`)。 |
22
22
  | `checked` | 结果属于 `PASS` / `WARN` / `FAIL` 的检查数,即**实际评估过**的项。 |
23
23
  | `pass` | 结果 `PASS` 的数量。 |
24
24
  | `warn` | 结果 `WARN` 的数量。 |
@@ -41,7 +41,7 @@
41
41
 
42
42
  - `checked = pass + warn + fail`(`advisory <= warn`)。
43
43
  - `error <= notRun`;`notRun` 统计的是“没拿到完整结论”的项,`NA` 不算没跑。
44
- - 常规完整跑(14 项、无缺失)满足 `checked + na + notRun = total`。
44
+ - 默认 `ds-c` 完整跑(14 项、无缺失)与 `--scope security` 完整跑(22 项、无缺失)都满足 `checked + na + notRun = total`;两份报告的 `total` 不同,不能混算通过率。
45
45
  - **例外**:若某项 `coverage: "partial"` 但结果仍是 `WARN`/`FAIL`,它会同时出现在 `checked` 与 `notRun` 中——已评估但不完整,不能当成 `full`。有缺失强制项时,`notRun` 会含不在 `checks[]` 里的 ID,此时上述加法可大于 `total`。
46
46
  - `metrics = { checked, total, declaredFields }`:`checked`/`total` 与 `coverage` 同源;`declaredFields` 是 capabilities/labels/commitments/disclosure/engines.dsh 五个可选声明面中实际声明的个数(0–5),只作上下文。
47
47
 
@@ -78,23 +78,45 @@
78
78
  - `checked`:真正产出 detail 的前提;`notChecked`:没产出的前提。
79
79
  - `checked.length + notChecked.length = required`;`failed` 是 `checked` 中结果为 `FAIL` 的子集。
80
80
  - `pass` 仅在 `failed` 为空**且** `notChecked` 为空时为 `true`。没给 `--install-spec`/`--market-index` 时 C-3.x 未查,`pass` 必为 `false`。
81
- - `entryBlocking` 不替代 `coverage`;它回答“阻断前提齐不齐”,`coverage` 回答“14 项各是什么状态”。
81
+ - `entryBlocking` 不替代 `coverage`;它回答“阻断前提齐不齐”,`coverage` 回答“`checks[]` 各是什么状态”(默认 14 项,security 22 项)。
82
82
 
83
- ## 5. 示例(1.3.0,good fixture,`--no-exec`)
83
+ ## 5. 示例(`--scope security` 示意,22 项;数字非实测)
84
84
 
85
85
  ```json
86
86
  {
87
- "total": 14, "mandatory": 11, "checked": 6,
88
- "pass": 5, "warn": 0, "advisory": 0, "fail": 1,
89
- "na": 1, "notRun": 7, "error": 7, "entryBlocking": null
87
+ "total": 22, "mandatory": 4, "checked": 14,
88
+ "pass": 12, "warn": 1, "advisory": 1, "fail": 1,
89
+ "na": 1, "notRun": 7, "error": 7, "entryBlocking": null,
90
+ "security": {
91
+ "scope": "security", "status": "fail",
92
+ "blocking": ["DSC-17"], "warn": [], "advisory": ["DSC-19"], "notRun": []
93
+ }
90
94
  }
91
95
  ```
92
96
 
93
- 读法:5 项 PASS + 1 项 FAIL = 6 项实际评估;1 项显式 NA;7 项 ERROR/未跑;`checked + na + notRun = 14`;`error (7) <= notRun (7)`。`status` 仍由强制项决定(此处因 FAIL 为 `fail`),不能只看 `coverage` 反推通过。
97
+ 读法:12 PASS + 1 WARN + 1 FAIL = 14 项实际评估;1 项显式 NA;7 项 ERROR/未跑;`checked + na + notRun = 22`;`error (7) <= notRun (7)`。`conclusion.status` 由 `--scope security` 的 4 个强制项(`DSC-15/16/17/21`)决定;`security.status=fail` 只是 security 块的独立结论,不把 `DS-C` 徽章改写成 fail。
94
98
 
95
- ## 6. 禁止事项
99
+ ## 6. security scope(C-15~C-22)
100
+
101
+ `--scope security` 是独立 opt-in scope,不并入 `ds-c` / `entry` / `mvp`。8 个 security 检查**始终**在报告的 `security` 块里运行;是否进入 `checks[]`、是否可 FAIL,取决于 scope。
102
+
103
+ | 项 | 语义 |
104
+ |---|---|
105
+ | 计入 `total` | **默认否**:`checks[]` 仍是 14,`security` 块单独存在、不计入 `coverage`。**security 是**:`checks[]` 22,`coverage.total=22`、`coverage.mandatory=4`。 |
106
+ | 默认 `security.scope` | `"advisory"`。8 项照常评估,但任何 `FAIL` 封顶为 `WARN`,不改 `conclusion`/exit;`security.status` 可为 `pass`/`warn`。 |
107
+ | `security.status=pass` | **security scope 下**:无 FAIL、无强制项结果不在 `PASS`/`WARN`/`FAIL`、无任何 WARN(含 advisory)。默认 advisory 模式只可能 `pass`/`warn`,且 `pass` 不代表覆盖完整。advisory 的 `not-run`(如 C-22 无元数据)不挡 `pass`,但必须留在 `security.notRun[]`;C-21 的传递依赖元数据缺失只记 check 级 `coverage: partial`,不等同 PASS。 |
108
+ | `security.status=fail` | **仅 security scope**:`DSC-15`/`DSC-16`/`DSC-17` 任一 FAIL。`DSC-21` 永不 FAIL;默认模式 FAIL 封顶 WARN,不会出现 fail。 |
109
+ | `security.status=warn` | 无 FAIL,但有 `DSC-21` 或 15/16/17 的 WARN,或 18/19/20/22 的 advisory WARN。 |
110
+ | `security.status=incomplete` | **security scope 下** `DSC-15`/`DSC-16`/`DSC-17`/`DSC-21` 任一结果不在 `PASS`/`WARN`/`FAIL`(即 `NA`/`ERROR`/`not-run`)。 |
111
+ | 与 DS-C 关系 | **独立**:security FAIL 不撤销 DS-C PASS;DS-C PASS 不证明 security 通过;反之亦然。 |
112
+ | 徽章 | **不得**发 security 徽章;市场只透传 `security.status`、`security.checks[]` 与 `security.notRun[]`,不按安全分排序/筛选。 |
113
+
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` 换算成第二套通过率。
115
+
116
+ ## 7. 禁止事项
96
117
 
97
118
  - 不得把 `na`、`notRun`、`error` 或 `partial` 写成 PASS。
98
119
  - 不得用 `coverage.pass / total` 当通过率或等级;`total` 含未跑项,且 `mandatory` 随 profile 变。
99
120
  - 不得把 `advisory` 计入 `fail` 或据其改变 `exitCode`。
100
121
  - 检查级 `coverage` 与报告级 `coverage` 同名不同义;引用时必须写全路径(`checks[].coverage` vs `coverage`)。
122
+ - 不得把 `security.status` 写成安全认证/审计结论;security 的 `pass` 只覆盖这 8 项的静态/元数据证据。