dsh-standards 1.0.3 → 1.2.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 (99) hide show
  1. package/40-checker/README.md +15 -0
  2. package/40-checker/bin/dsh-flow-collect.mjs +3 -0
  3. package/40-checker/bin/dsh-levels.mjs +13 -2
  4. package/40-checker/fixtures/bundle-patch-only/cordis.patch.yml +3 -0
  5. package/40-checker/fixtures/bundle-patch-only/package.json +12 -0
  6. package/40-checker/fixtures/c12-command-arg/index.mjs +7 -0
  7. package/40-checker/fixtures/c12-command-arg/package.json +10 -0
  8. package/40-checker/fixtures/c12-comment/index.mjs +3 -0
  9. package/40-checker/fixtures/c12-comment/package.json +10 -0
  10. package/40-checker/fixtures/c12-compare/index.mjs +3 -0
  11. package/40-checker/fixtures/c12-compare/package.json +10 -0
  12. package/40-checker/fixtures/c12-doc-string/index.mjs +13 -0
  13. package/40-checker/fixtures/c12-doc-string/package.json +10 -0
  14. package/40-checker/fixtures/c12-grant/index.mjs +3 -0
  15. package/40-checker/fixtures/c12-grant/package.json +10 -0
  16. package/40-checker/fixtures/c12-string/index.mjs +3 -0
  17. package/40-checker/fixtures/c12-string/package.json +10 -0
  18. package/40-checker/fixtures/declaration-file/index.d.ts +1 -0
  19. package/40-checker/fixtures/declaration-file/index.mjs +9 -0
  20. package/40-checker/fixtures/declaration-file/package.json +10 -0
  21. package/40-checker/fixtures/import-local-side-effect/index.mjs +11 -0
  22. package/40-checker/fixtures/import-local-side-effect/package.json +21 -0
  23. package/40-checker/fixtures/import-local-side-effect/side-effectful.mjs +5 -0
  24. package/40-checker/fixtures/import-open-readonly/index.mjs +14 -0
  25. package/40-checker/fixtures/import-open-readonly/package.json +19 -0
  26. package/40-checker/fixtures/import-package-only/index.mjs +11 -0
  27. package/40-checker/fixtures/import-package-only/package.json +21 -0
  28. package/40-checker/fixtures/import-parse-error/index.mjs +5 -0
  29. package/40-checker/fixtures/import-parse-error/package.json +10 -0
  30. package/40-checker/fixtures/import-side-effect/index.mjs +14 -0
  31. package/40-checker/fixtures/import-side-effect/package.json +19 -0
  32. package/40-checker/fixtures/import-side-effect-nonentry/index.mjs +9 -0
  33. package/40-checker/fixtures/import-side-effect-nonentry/package.json +19 -0
  34. package/40-checker/fixtures/import-side-effect-nonentry/selfcheck.mjs +8 -0
  35. package/40-checker/fixtures/import-void-main/index.mjs +16 -0
  36. package/40-checker/fixtures/import-void-main/package.json +19 -0
  37. package/40-checker/fixtures/no-source/package.json +19 -0
  38. package/40-checker/fixtures/parse-error-nonentry/index.mjs +2 -0
  39. package/40-checker/fixtures/parse-error-nonentry/package.json +10 -0
  40. package/40-checker/fixtures/parse-error-nonentry/references/broken.js +1 -0
  41. package/40-checker/scripts/bundle-smoke.mjs +3 -0
  42. package/40-checker/scripts/entry18-ci.mjs +4 -3
  43. package/40-checker/scripts/entry18-web.mjs +4 -1
  44. package/40-checker/scripts/flow-collect.mjs +494 -0
  45. package/40-checker/scripts/flow-sample-ci.mjs +4 -4
  46. package/40-checker/scripts/host-farm.mjs +2 -1
  47. package/40-checker/scripts/journey-config-observe.mjs +5 -3
  48. package/40-checker/scripts/journey-runtime.mjs +208 -83
  49. package/40-checker/scripts/level-audit.mjs +91 -19
  50. package/40-checker/scripts/m2-runner.mjs +4 -3
  51. package/40-checker/scripts/runtime-child.mjs +4 -0
  52. package/40-checker/src/cli.mjs +11 -3
  53. package/40-checker/src/declarations.mjs +514 -19
  54. package/40-checker/src/flow-evidence.mjs +431 -0
  55. package/40-checker/src/index.mjs +3 -0
  56. package/40-checker/src/manifest.mjs +5 -0
  57. package/40-checker/src/remediation.mjs +132 -0
  58. package/40-checker/src/report.mjs +19 -2
  59. package/40-checker/src/sandbox-env.mjs +78 -0
  60. package/40-checker/test/checks.test.mjs +243 -1
  61. package/40-checker/test/flow-evidence.test.mjs +295 -0
  62. package/40-checker/test/remediation.test.mjs +300 -0
  63. package/40-checker/vendor/babel/README.md +10 -0
  64. package/40-checker/vendor/babel/index.cjs +3 -0
  65. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/LICENSE +22 -0
  66. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/README.md +19 -0
  67. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.d.ts +41 -0
  68. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.js +286 -0
  69. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/package.json +31 -0
  70. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/LICENSE +22 -0
  71. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/README.md +19 -0
  72. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier-browser.js +62 -0
  73. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier.js +60 -0
  74. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.d.ts +28 -0
  75. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.js +27 -0
  76. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/package.json +45 -0
  77. package/40-checker/vendor/babel/node_modules/@babel/parser/CHANGELOG.md +1073 -0
  78. package/40-checker/vendor/babel/node_modules/@babel/parser/LICENSE +19 -0
  79. package/40-checker/vendor/babel/node_modules/@babel/parser/README.md +19 -0
  80. package/40-checker/vendor/babel/node_modules/@babel/parser/bin/babel-parser.js +14 -0
  81. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.d.ts +1047 -0
  82. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.js +14612 -0
  83. package/40-checker/vendor/babel/node_modules/@babel/parser/package.json +57 -0
  84. package/40-checker/vendor/babel/node_modules/@babel/parser/typings/babel-parser.d.ts +636 -0
  85. package/40-checker/vendor/babel/node_modules/@babel/types/LICENSE +22 -0
  86. package/40-checker/vendor/babel/node_modules/@babel/types/README.md +19 -0
  87. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index-legacy.d.ts +2765 -0
  88. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.d.ts +3668 -0
  89. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js +10758 -0
  90. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js.flow +2618 -0
  91. package/40-checker/vendor/babel/node_modules/@babel/types/package.json +38 -0
  92. package/CHANGELOG.md +66 -0
  93. package/bin/dsh-flow-collect.mjs +4 -0
  94. package/package.json +2 -1
  95. package/spec/README.md +2 -0
  96. package/spec/flow/README.md +23 -2
  97. package/spec/flow-evidence.schema.json +217 -0
  98. package/spec/levels.json +2 -0
  99. package/spec/remediation.json +478 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Environment policy for child processes that may load third-party plugin code.
3
+ *
4
+ * A plugin boots on first import: anything it can read from `process.env` is
5
+ * already exfiltratable before a single check runs. `...process.env` therefore
6
+ * must never be forwarded to `dsh plugin add`, a host boot, or a runtime child.
7
+ * The policy is an allowlist (not a denylist) so a newly named credential does
8
+ * not silently leak; the secret-shaped name filter stays as a second gate in
9
+ * case an allowlisted name is ambiguous.
10
+ *
11
+ * Callers that need isolation (temporary HOME, DSH_HOME, TMPDIR) pass those as
12
+ * explicit overrides. Overrides are the caller's own values, never inherited.
13
+ */
14
+
15
+ const ALLOWED = new Set([
16
+ // Portable process essentials.
17
+ 'PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'PWD', 'SHELL',
18
+ 'USER', 'LOGNAME', 'LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES',
19
+ 'TZ', 'TERM', 'NO_COLOR', 'FORCE_COLOR', 'COLORTERM',
20
+ // Windows needs these to locate node/npm/tmp; they carry no credentials.
21
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR', 'COMSPEC', 'PATHEXT', 'OS',
22
+ 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_ARCHITEW6432',
23
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'PROGRAMFILES',
24
+ 'PROGRAMFILES(X86)', 'COMMONPROGRAMFILES', 'HOMEDRIVE', 'HOMEPATH',
25
+ ])
26
+
27
+ const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|CREDENTIAL|AUTH|COOKIE|SESSION|_KEY$)/i
28
+
29
+ export function isSecretEnvName(name) {
30
+ return SECRET_NAME.test(String(name ?? ''))
31
+ }
32
+
33
+ export function isAllowedEnvName(name) {
34
+ return ALLOWED.has(String(name)) && !isSecretEnvName(name)
35
+ }
36
+
37
+ /**
38
+ * Build the child environment: inherited allowlisted keys + caller overrides.
39
+ * Overrides are trusted call values (paths the caller just created), not
40
+ * inherited state, so they are not filtered.
41
+ */
42
+ export function sanitizedEnv(overrides = {}) {
43
+ const env = {}
44
+ for (const [key, value] of Object.entries(process.env)) {
45
+ if (value === undefined || !isAllowedEnvName(key)) continue
46
+ env[key] = value
47
+ }
48
+ for (const [key, value] of Object.entries(overrides)) {
49
+ if (value === undefined || value === null) continue
50
+ env[key] = String(value)
51
+ }
52
+ return env
53
+ }
54
+
55
+ /**
56
+ * In-process variant for scripts that boot a plugin in this very process
57
+ * (bundle smoke, runtime child). It deletes everything not allowlisted, so a
58
+ * direct `node script.mjs` invocation gets the same policy as a spawned one.
59
+ * Returns the removed key names (never values) for logging.
60
+ */
61
+ export function scrubProcessEnv() {
62
+ const removed = []
63
+ for (const key of Object.keys(process.env)) {
64
+ if (isAllowedEnvName(key)) continue
65
+ removed.push(key)
66
+ delete process.env[key]
67
+ }
68
+ return removed
69
+ }
70
+
71
+ export function envPolicy() {
72
+ return {
73
+ mode: 'allowlist',
74
+ allow: [...ALLOWED].sort(),
75
+ denyNamePattern: SECRET_NAME.source,
76
+ note: 'tokens/keys/secrets/credentials are dropped even if a future allowlist entry matches',
77
+ }
78
+ }
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { runManifestChecks } from '../src/manifest.mjs'
6
- import { checkCapabilities, checkCredentials, checkLabels } from '../src/declarations.mjs'
6
+ import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from '../src/declarations.mjs'
7
7
  import { readSources } from '../src/util.mjs'
8
8
  import { buildReport } from '../src/report.mjs'
9
9
  import { parseInstallSpec } from '../src/install-spec.mjs'
@@ -21,6 +21,14 @@ test('good fixture passes manifest and version checks', () => {
21
21
  assert.equal(result.checks[2].result, 'NA')
22
22
  })
23
23
 
24
+ test('patch-only bundle reports C-1.2 as NA, not WARN', () => {
25
+ const result = runManifestChecks(path.join(here, '..', 'fixtures', 'bundle-patch-only'))
26
+ const dsc01 = result.checks.find((check) => check.id === 'DSC-01')
27
+ const entry = dsc01.details.find((detail) => detail.item === 'C-1.2')
28
+ assert.equal(entry.result, 'NA')
29
+ assert.match(entry.reason, /patch-only bundle/)
30
+ })
31
+
24
32
  test('bad fixture fails version and warns licence', () => {
25
33
  const result = runManifestChecks(bad)
26
34
  assert.equal(result.checks[0].result, 'WARN')
@@ -170,3 +178,237 @@ test('entry: static claim recognizes without runtime', () => {
170
178
  assert.equal(report.conclusion.level, 'DSH-ENTRY recognized')
171
179
  assert.equal(report.conclusion.exitCode, 0)
172
180
  })
181
+
182
+ test('DSC-10.4: flags a module-scope fs mutation in the entry module', () => {
183
+ const dir = path.join(here, '..', 'fixtures', 'import-side-effect')
184
+ const result = runManifestChecks(dir)
185
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
186
+ assert.equal(check.result, 'WARN', check.reason)
187
+ assert.ok(check.details.some((detail) => /top-level fs mutation/.test(detail.reason)), JSON.stringify(check.details))
188
+ })
189
+
190
+ test('DSC-10.4: ignores a module-scope fs mutation outside the entry module', () => {
191
+ const dir = path.join(here, '..', 'fixtures', 'import-side-effect-nonentry')
192
+ const result = runManifestChecks(dir)
193
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
194
+ assert.equal(check.result, 'PASS', check.reason)
195
+ })
196
+
197
+ test('DSC-10.4: a clean entry module still passes', () => {
198
+ const result = runManifestChecks(good)
199
+ const check = checkImportTimeAdvisory(readSources(good), result.entry?.relative)
200
+ assert.equal(check.result, 'PASS', check.reason)
201
+ })
202
+
203
+ test('DSC-10.1/2: the existing patterns are entry-scoped too', () => {
204
+ const dir = path.join(here, '..', 'fixtures', 'import-side-effect-nonentry')
205
+ const result = runManifestChecks(dir)
206
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
207
+ assert.equal(check.result, 'PASS', check.reason)
208
+ })
209
+
210
+ test('DSC-10.4: a read-only openSync is not a write mutation', () => {
211
+ const dir = path.join(here, '..', 'fixtures', 'import-open-readonly')
212
+ const result = runManifestChecks(dir)
213
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
214
+ assert.equal(check.result, 'PASS', check.reason)
215
+ })
216
+
217
+ test('DSC-10: no source files is NA, not WARN', () => {
218
+ const dir = path.join(here, '..', 'fixtures', 'no-source')
219
+ const check = checkImportTimeAdvisory(readSources(dir), 'index.mjs')
220
+ assert.equal(check.result, 'NA', check.reason)
221
+ assert.match(check.reason, /no source/)
222
+ })
223
+
224
+ test('DSC-10.2: flags an immediate module-scope void main()', () => {
225
+ const dir = path.join(here, '..', 'fixtures', 'import-void-main')
226
+ const result = runManifestChecks(dir)
227
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
228
+ assert.equal(check.result, 'WARN', check.reason)
229
+ assert.ok(check.details.some((detail) => /immediate main/.test(detail.reason)), JSON.stringify(check.details))
230
+ })
231
+
232
+ test('DSC-10.1/2: AST catches top-level await, timer, process hooks and globals', () => {
233
+ const text = [
234
+ "await Promise.resolve()",
235
+ 'for await (const item of []) {}',
236
+ "setInterval(() => {}, 1000)",
237
+ "process.on('SIGINT', () => {})",
238
+ "process.env.DSH_FIXTURE = '1'",
239
+ 'globalThis.__dshFixture = true',
240
+ ].join('\n')
241
+ const check = checkImportTimeAdvisory([{ relative: 'index.mjs', text }], 'index.mjs')
242
+ assert.equal(check.result, 'WARN', check.reason)
243
+ assert.equal(check.details.filter((detail) => /top-level await/.test(detail.reason)).length, 2)
244
+ for (const pattern of ['top-level setInterval', 'top-level process.on', 'top-level process.env assignment', 'top-level globalThis assignment']) {
245
+ assert.ok(check.details.some((detail) => detail.reason.includes(pattern)), `${pattern}: ${JSON.stringify(check.details)}`)
246
+ }
247
+ })
248
+
249
+ test('DSC-10.4: member fs writes and module-scope control flow count', () => {
250
+ const text = [
251
+ "import * as fs from 'node:fs'",
252
+ 'if (process.env.DSH_FIXTURE) {',
253
+ " fs.writeFileSync('/tmp/dsh-fixture-member-write.txt', 'x')",
254
+ '}',
255
+ ].join('\n')
256
+ const check = checkImportTimeAdvisory([{ relative: 'index.mjs', text }], 'index.mjs')
257
+ assert.equal(check.result, 'WARN', check.reason)
258
+ assert.ok(check.details.some((detail) => /top-level fs mutation/.test(detail.reason)), JSON.stringify(check.details))
259
+ })
260
+
261
+ test('DSC-10: function, arrow and class-method bodies are not module-scope side effects', () => {
262
+ const text = [
263
+ "function a() { writeFileSync('/tmp/a', 'x') }",
264
+ "const b = () => spawnSync('cmd')",
265
+ "class C { m() { process.on('x', () => {}) } }",
266
+ 'const d = { n() { setInterval(() => {}, 1) } }',
267
+ ].join('\n')
268
+ const check = checkImportTimeAdvisory([{ relative: 'index.mjs', text }], 'index.mjs')
269
+ assert.equal(check.result, 'PASS', check.reason)
270
+ })
271
+
272
+ test('DSC-12 C-12.4: ignores comment-only literals', () => {
273
+ const dir = path.join(here, '..', 'fixtures', 'c12-comment')
274
+ const check = checkCredentials({}, readSources(dir))
275
+ assert.equal(check.result, 'PASS', check.reason)
276
+ })
277
+
278
+ test('DSC-12 C-12.4: ignores equality comparisons', () => {
279
+ const dir = path.join(here, '..', 'fixtures', 'c12-compare')
280
+ const check = checkCredentials({}, readSources(dir))
281
+ assert.equal(check.result, 'PASS', check.reason)
282
+ })
283
+
284
+ test('DSC-12 C-12.4: still flags an actual set() grant', () => {
285
+ const dir = path.join(here, '..', 'fixtures', 'c12-grant')
286
+ const check = checkCredentials({}, readSources(dir))
287
+ assert.equal(check.result, 'WARN', check.reason)
288
+ assert.ok(check.details.some((detail) => detail.item === 'C-12.4'), JSON.stringify(check.details))
289
+ })
290
+
291
+ test('DSC-12 C-12.4: console-only documentation string is not a grant', () => {
292
+ const dir = path.join(here, '..', 'fixtures', 'c12-string')
293
+ const check = checkCredentials({}, readSources(dir))
294
+ assert.equal(check.result, 'PASS', check.reason)
295
+ assert.ok(!check.details.some((detail) => detail.item === 'C-12.4'), JSON.stringify(check.details))
296
+ })
297
+
298
+ test('DSC-12 C-12.4: console/desc documentation strings are not grants', () => {
299
+ const dir = path.join(here, '..', 'fixtures', 'c12-doc-string')
300
+ const check = checkCredentials({}, readSources(dir))
301
+ assert.equal(check.result, 'PASS', check.reason)
302
+ assert.ok(!check.details.some((detail) => detail.item === 'C-12.4'), JSON.stringify(check.details))
303
+ })
304
+
305
+ test('DSC-11 C-11.4: console/desc documentation strings are not danger literals', () => {
306
+ const dir = path.join(here, '..', 'fixtures', 'c12-doc-string')
307
+ const check = checkCapabilities({}, readSources(dir))
308
+ assert.ok(!check.details.some((detail) => detail.item === 'C-11.4'), JSON.stringify(check.details))
309
+ })
310
+
311
+ test('DSC-11 C-11.4: --add-dir alone is a C-12.4 concern, not C-11.4', () => {
312
+ const sources = [{ relative: 'index.mjs', text: "spawn('dsh', ['--add-dir', './docs'])\n" }]
313
+ const check = checkCapabilities({}, sources)
314
+ assert.ok(!check.details.some((detail) => detail.item === 'C-11.4'), JSON.stringify(check.details))
315
+ })
316
+
317
+ test('DSC-12 C-12.4: spawn args with --add-dir are a real command string', () => {
318
+ const dir = path.join(here, '..', 'fixtures', 'c12-command-arg')
319
+ const check = checkCredentials({}, readSources(dir))
320
+ assert.equal(check.result, 'WARN', check.reason)
321
+ assert.ok(check.details.some((detail) => detail.item === 'C-12.4'), JSON.stringify(check.details))
322
+ })
323
+
324
+ test('DSC-12 C-12.4: set/resolve/mode/switch contexts are real grants', () => {
325
+ const text = [
326
+ "svc.set(session, 'danger-full-access')",
327
+ "svc.resolve('danger-full-access')",
328
+ "const policy = { sandboxPolicy: { mode: 'danger-full-access' } }",
329
+ "switch (mode) { case 'danger-full-access': break }",
330
+ ].join('\n')
331
+ const check = checkCredentials({}, [{ relative: 'index.mjs', text }])
332
+ assert.equal(check.result, 'WARN', check.reason)
333
+ const detail = check.details.find((item) => item.item === 'C-12.4')
334
+ assert.ok(detail, JSON.stringify(check.details))
335
+ assert.match(detail.reason, /\b4\b/)
336
+ })
337
+
338
+ test('DSC-12 C-12.4: nested command properties outrank documentation keys', () => {
339
+ const check = checkCredentials({}, [{ relative: 'index.mjs', text: "const policy = { sandboxPolicy: { description: 'danger-full-access' } }\n" }])
340
+ assert.equal(check.result, 'WARN', check.reason)
341
+ })
342
+
343
+ test('DSC-12 C-12.4: an unknown context stays a conservative warning', () => {
344
+ const check = checkCredentials({}, [{ relative: 'index.mjs', text: "const mode = 'danger-full-access'\n" }])
345
+ assert.equal(check.result, 'WARN', check.reason)
346
+ })
347
+
348
+ test('DSC-12 C-12.4: an unparsable source is not-run, not PASS', () => {
349
+ const check = checkCredentials({}, [{ relative: 'index.mjs', text: 'const broken = {\n' }])
350
+ assert.equal(check.result, 'ERROR', check.reason)
351
+ assert.equal(check.coverage, 'not-run')
352
+ assert.ok(check.details.some((detail) => detail.item === 'C-12.4' && /not-run/.test(detail.reason)), JSON.stringify(check.details))
353
+ })
354
+
355
+ test('DSC-10.3: follows a local relative import and flags its top-level fs mutation', () => {
356
+ const dir = path.join(here, '..', 'fixtures', 'import-local-side-effect')
357
+ const result = runManifestChecks(dir)
358
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
359
+ assert.equal(check.result, 'WARN', check.reason)
360
+ assert.ok(check.details.some((detail) => /top-level fs mutation/.test(detail.reason) && /side-effectful\.mjs/.test(detail.reason)), JSON.stringify(check.details))
361
+ })
362
+
363
+ test('DSC-10.3: a package-only entry is PASS with a C-10.3 not-run coverage note', () => {
364
+ const dir = path.join(here, '..', 'fixtures', 'import-package-only')
365
+ const result = runManifestChecks(dir)
366
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
367
+ assert.equal(check.result, 'PASS', check.reason)
368
+ const note = check.details.find((detail) => detail.item === 'C-10.3')
369
+ assert.ok(note, JSON.stringify(check.details))
370
+ assert.match(note.reason, /not-run/)
371
+ assert.match(note.reason, /node:path/)
372
+ })
373
+
374
+ test('DSC-10.3: an unresolved local import records not-run without becoming a WARN', () => {
375
+ const sources = [{ relative: 'index.mjs', text: "import './missing-module.mjs'\n" }]
376
+ const check = checkImportTimeAdvisory(sources, 'index.mjs')
377
+ assert.equal(check.result, 'PASS', check.reason)
378
+ assert.ok(check.details.some((detail) => detail.item === 'C-10.3' && /not-run/.test(detail.reason)), JSON.stringify(check.details))
379
+ })
380
+
381
+ test('DSC-10.3: a require inside a function is not an import-time edge', () => {
382
+ const sources = [
383
+ { relative: 'index.mjs', text: "function load() { return require('./side-effectful.mjs') }\n" },
384
+ { relative: 'side-effectful.mjs', text: "writeFileSync('/tmp/x', 'x')\n" },
385
+ ]
386
+ const check = checkImportTimeAdvisory(sources, 'index.mjs')
387
+ assert.equal(check.result, 'PASS', check.reason)
388
+ assert.ok(!check.details.some((detail) => /side-effectful/.test(detail.reason)), JSON.stringify(check.details))
389
+ })
390
+
391
+ test('DSC-10: an unparsable entry is ERROR/not-run, not PASS', () => {
392
+ const dir = path.join(here, '..', 'fixtures', 'import-parse-error')
393
+ const result = runManifestChecks(dir)
394
+ const check = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
395
+ assert.equal(check.result, 'ERROR', check.reason)
396
+ assert.equal(check.coverage, 'not-run')
397
+ })
398
+
399
+ test('DSC-10/DSC-12: a broken .d.ts is skipped, not ERROR', () => {
400
+ const dir = path.join(here, '..', 'fixtures', 'declaration-file')
401
+ const result = runManifestChecks(dir)
402
+ const c10 = checkImportTimeAdvisory(readSources(dir), result.entry?.relative)
403
+ const c12 = checkCredentials({}, readSources(dir))
404
+ assert.equal(c10.result, 'PASS', c10.reason)
405
+ assert.equal(c12.result, 'PASS', c12.reason)
406
+ })
407
+
408
+ test('DSC-12 C-12.4: one unparsable non-entry file is partial, not ERROR', () => {
409
+ const dir = path.join(here, '..', 'fixtures', 'parse-error-nonentry')
410
+ const check = checkCredentials({}, readSources(dir))
411
+ assert.notEqual(check.result, 'ERROR', check.reason)
412
+ assert.equal(check.coverage, 'partial')
413
+ assert.ok(check.details.some((detail) => detail.item === 'C-12.4' && /not-run/.test(detail.reason)), JSON.stringify(check.details))
414
+ })
@@ -0,0 +1,295 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import {
9
+ FLOW_IDS, evidenceHash, finalizeEvidence, journeyAggregate, treeSha256, validateFlowEvidence,
10
+ } from '../src/flow-evidence.mjs'
11
+ import { isSecretEnvName, sanitizedEnv } from '../src/sandbox-env.mjs'
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
14
+ const STD = path.resolve(HERE, '..', '..')
15
+ const AUDIT = path.join(STD, '40-checker', 'scripts', 'level-audit.mjs')
16
+ const COLLECT = path.join(STD, '40-checker', 'bin', 'dsh-flow-collect.mjs')
17
+ const GOOD = path.join(STD, '40-checker', 'fixtures', 'good')
18
+ const TEMPLATE = path.join(STD, 'examples', 'plugin-template')
19
+ const ISO = '2026-11-06T12:00:00.000Z'
20
+ const HASH = `sha256:${'a'.repeat(64)}`
21
+
22
+ function check(id, overrides = {}) {
23
+ return {
24
+ id,
25
+ kind: 'observed',
26
+ command: ['node', '-e', 'process.exit(0)'],
27
+ exitCode: 0,
28
+ startedAt: ISO,
29
+ endedAt: ISO,
30
+ stdout: { path: `logs/${id}.out.log`, sha256: HASH },
31
+ assertions: [{ claim: `${id} observed`, observed: true }],
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ function evidenceDocument({ journeys = {}, subject, ...rest } = {}) {
37
+ const base = {
38
+ schema: 'dsh-flow-evidence/2',
39
+ generatedAt: ISO,
40
+ producer: { command: ['node', '40-checker/bin/dsh-flow-collect.mjs'], at: ISO, kind: 'local' },
41
+ subject: subject ?? {
42
+ name: '@dsh-fixtures/good-plugin',
43
+ version: '1.0.0',
44
+ integrity: null,
45
+ tarballSha256: HASH,
46
+ commit: null,
47
+ treeSha256: HASH,
48
+ },
49
+ host: { dsh: '0.2.1-rc.5', node: 'v24.22.1', platform: 'linux-x64' },
50
+ journeys: Object.fromEntries(FLOW_IDS.map((id) => [id, { status: 'pass', checks: [check(`${id}.run`)] }])),
51
+ claims: { claimLevel: 'self-attested', notClaimed: ['certification'] },
52
+ conclusion: 'flow-pass',
53
+ ...rest,
54
+ }
55
+ for (const [id, override] of Object.entries(journeys)) base.journeys[id] = override
56
+ return finalizeEvidence(base)
57
+ }
58
+
59
+ function writeTemp(name, value) {
60
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-flow-evidence-test-'))
61
+ const file = path.join(dir, name)
62
+ fs.writeFileSync(file, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`)
63
+ return { dir, file, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }
64
+ }
65
+
66
+ function runAudit(flowFile) {
67
+ const run = spawnSync(process.execPath, [AUDIT, GOOD, '--format', 'json', '--flow-evidence', flowFile], {
68
+ encoding: 'utf8', timeout: 60_000,
69
+ })
70
+ assert.equal(run.status, 0, run.stderr)
71
+ return JSON.parse(run.stdout)
72
+ }
73
+
74
+ test('sandbox-env: inherited secrets are dropped and only allowlisted names survive', () => {
75
+ process.env.DSH_TEST_TOKEN = 'do-not-leak'
76
+ process.env.DSH_TEST_API_KEY = 'do-not-leak'
77
+ try {
78
+ const env = sanitizedEnv()
79
+ assert.equal(env.DSH_TEST_TOKEN, undefined)
80
+ assert.equal(env.DSH_TEST_API_KEY, undefined)
81
+ assert.equal(env.DSH_HOME, undefined)
82
+ assert.equal(env.PATH, process.env.PATH)
83
+ assert.equal(isSecretEnvName('GH_TOKEN'), true)
84
+ assert.equal(isSecretEnvName('DEEPSEEK_API_KEY'), true)
85
+ assert.equal(isSecretEnvName('PATH'), false)
86
+ assert.equal(sanitizedEnv({ DSH_HOME: '/tmp/isolated' }).DSH_HOME, '/tmp/isolated')
87
+ } finally {
88
+ delete process.env.DSH_TEST_TOKEN
89
+ delete process.env.DSH_TEST_API_KEY
90
+ }
91
+ })
92
+
93
+ test('flow-evidence: a well-formed v2 document validates and hashes consistently', () => {
94
+ const doc = evidenceDocument()
95
+ const verdict = validateFlowEvidence(doc)
96
+ assert.equal(verdict.kind, 'v2')
97
+ assert.equal(verdict.valid, true, verdict.errors.join('; '))
98
+ assert.equal(verdict.declaredHash, evidenceHash(doc))
99
+ assert.equal(verdict.journeys.J1.derived, 'pass')
100
+ })
101
+
102
+ test('level-audit: v1 journey/runtime schemas are deprecated and never a pass', () => {
103
+ const docs = [
104
+ { schema: 'dsh-flow-journey/1', package: '@dsh-fixtures/good-plugin', checks: [] },
105
+ { schema: 'dsh-flow-runtime/1', environment: {}, checks: [] },
106
+ ]
107
+ for (const doc of docs) {
108
+ const temp = writeTemp('v1.json', doc)
109
+ try {
110
+ const result = runAudit(temp.file)
111
+ assert.equal(result.levels.flow.state, 'deprecated')
112
+ assert.equal(result.levels.flow.deprecated, true)
113
+ assert.equal(result.levels.flow.passed, 0)
114
+ assert.notEqual(result.levels.flow.state, 'runtime')
115
+ } finally {
116
+ temp.cleanup()
117
+ }
118
+ }
119
+ })
120
+
121
+ test('level-audit: bare {Jx:{status:pass}} rows are deprecated and never a pass', () => {
122
+ const temp = writeTemp('bare.json', Object.fromEntries(FLOW_IDS.map(id => [id, { status: 'pass' }])))
123
+ try {
124
+ const result = runAudit(temp.file)
125
+ assert.equal(result.levels.flow.state, 'deprecated')
126
+ assert.equal(result.levels.flow.passed, 0)
127
+ assert.notEqual(result.levels.flow.state, 'runtime')
128
+ } finally {
129
+ temp.cleanup()
130
+ }
131
+ })
132
+
133
+ test('level-audit: v2 with all eight journeys passing is runtime', () => {
134
+ const temp = writeTemp('v2-pass.json', evidenceDocument())
135
+ try {
136
+ const result = runAudit(temp.file)
137
+ assert.equal(result.levels.flow.state, 'runtime')
138
+ assert.equal(result.levels.flow.passed, 8)
139
+ assert.equal(result.levels.flow.evidenceHash.ok, true)
140
+ } finally {
141
+ temp.cleanup()
142
+ }
143
+ })
144
+
145
+ test('level-audit: a forged v2 pass with a non-zero exitCode is not a pass', () => {
146
+ // Declared pass on every journey, but J4 carries exitCode 1: the aggregate
147
+ // contradicts the declaration, so the document must fail validation.
148
+ const doc = evidenceDocument({
149
+ journeys: { J4: { status: 'pass', checks: [check('J4.run', { exitCode: 1 })] } },
150
+ })
151
+ const temp = writeTemp('forged.json', doc)
152
+ try {
153
+ const result = runAudit(temp.file)
154
+ assert.notEqual(result.levels.flow.state, 'runtime')
155
+ assert.equal(result.levels.flow.passed, 0)
156
+ assert.equal(result.levels.flow.invalid, true)
157
+ assert.match(result.levels.flow.reason, /aggregate|exitCode|declares pass/i)
158
+ } finally {
159
+ temp.cleanup()
160
+ }
161
+ })
162
+
163
+ test('level-audit: an honest v2 with one failed journey is failed, not runtime', () => {
164
+ const doc = evidenceDocument({
165
+ journeys: { J4: { status: 'fail', checks: [check('J4.run', { exitCode: 1 })] } },
166
+ conclusion: 'flow-fail',
167
+ })
168
+ const temp = writeTemp('failed.json', doc)
169
+ try {
170
+ const result = runAudit(temp.file)
171
+ assert.equal(result.levels.flow.state, 'failed')
172
+ assert.equal(result.levels.flow.passed, 7)
173
+ assert.equal(result.levels.flow.journeys.J4.status, 'fail')
174
+ } finally {
175
+ temp.cleanup()
176
+ }
177
+ })
178
+
179
+ test('level-audit: a v2 journey without checks does not count as a pass', () => {
180
+ const doc = evidenceDocument({ journeys: { J5: { status: 'not-run', checks: [] } } })
181
+ const temp = writeTemp('empty.json', doc)
182
+ try {
183
+ const result = runAudit(temp.file)
184
+ assert.notEqual(result.levels.flow.state, 'runtime')
185
+ assert.equal(result.levels.flow.passed, 7)
186
+ } finally {
187
+ temp.cleanup()
188
+ }
189
+ })
190
+
191
+ test('level-audit: a tampered evidence hash is invalid, not runtime', () => {
192
+ const doc = evidenceDocument()
193
+ doc.chain.evidenceHash = HASH
194
+ const temp = writeTemp('tampered.json', doc)
195
+ try {
196
+ const result = runAudit(temp.file)
197
+ assert.equal(result.levels.flow.state, 'invalid')
198
+ assert.equal(result.levels.flow.passed, 0)
199
+ assert.match(result.levels.flow.reason, /evidenceHash/)
200
+ } finally {
201
+ temp.cleanup()
202
+ }
203
+ })
204
+
205
+ test('level-audit: a declared blocked journey is blocked, not failed', () => {
206
+ const doc = evidenceDocument({
207
+ journeys: { J6: { status: 'blocked', reason: 'no --previous package supplied', checks: [check('J6.upgrade', { exitCode: 1 })] } },
208
+ conclusion: 'flow-blocked',
209
+ })
210
+ const temp = writeTemp('blocked.json', doc)
211
+ try {
212
+ const result = runAudit(temp.file)
213
+ assert.equal(result.levels.flow.state, 'blocked')
214
+ assert.equal(result.levels.flow.passed, 7)
215
+ } finally {
216
+ temp.cleanup()
217
+ }
218
+ })
219
+
220
+ test('flow-collect: static output is v2, validates, and the audit can read it', () => {
221
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-flow-collect-test-'))
222
+ try {
223
+ const copy = path.join(dir, 'plugin')
224
+ fs.cpSync(TEMPLATE, copy, { recursive: true })
225
+ const out = path.join(dir, 'flow.json')
226
+ const run = spawnSync(process.execPath, [COLLECT, '--package', copy, '--static', '--out', out], {
227
+ encoding: 'utf8', timeout: 120_000,
228
+ })
229
+ assert.equal(run.status, 0, `${run.stderr}\n${run.stdout}`)
230
+ const doc = JSON.parse(fs.readFileSync(out, 'utf8'))
231
+ assert.equal(doc.schema, 'dsh-flow-evidence/2')
232
+ const verdict = validateFlowEvidence(doc)
233
+ assert.equal(verdict.valid, true, verdict.errors.join('; '))
234
+ assert.deepEqual(Object.keys(doc.journeys), FLOW_IDS)
235
+ assert.equal(doc.subject.treeStable, true)
236
+ assert.equal(doc.subject.treeSha256, treeSha256(TEMPLATE).hash)
237
+ assert.equal(doc.chain.evidenceHash, evidenceHash(doc))
238
+ assert.equal(journeyAggregate(doc.journeys.J1.checks), 'static')
239
+
240
+ // The audit consumes the collector output without upgrading it to runtime.
241
+ const audit = spawnSync(process.execPath, [AUDIT, copy, '--format', 'json', '--flow-evidence', out], {
242
+ encoding: 'utf8', timeout: 60_000,
243
+ })
244
+ assert.equal(audit.status, 0, audit.stderr)
245
+ const result = JSON.parse(audit.stdout)
246
+ assert.equal(result.levels.flow.schema, 'dsh-flow-evidence/2')
247
+ assert.notEqual(result.levels.flow.state, 'runtime')
248
+ assert.notEqual(result.levels.flow.state, 'invalid')
249
+ assert.notEqual(result.levels.flow.state, 'deprecated')
250
+ assert.equal(result.levels.flow.passed, 0)
251
+ assert.equal(result.levels.flow.evidenceHash.ok, true)
252
+ } finally {
253
+ fs.rmSync(dir, { recursive: true, force: true })
254
+ }
255
+ })
256
+
257
+ test('flow-collect: a failed journey exits 1 and prints the five-element hint', () => {
258
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-flow-collect-fail-'))
259
+ try {
260
+ const plugin = path.join(dir, 'plugin')
261
+ fs.mkdirSync(plugin)
262
+ fs.writeFileSync(path.join(plugin, 'package.json'), `${JSON.stringify({
263
+ name: 'dsh-flow-bad-fixture', version: '1.0.0', type: 'module', main: './index.mjs',
264
+ files: ['index.mjs'], dependencies: { 'workspace-only': 'workspace:*' },
265
+ }, null, 2)}\n`)
266
+ fs.writeFileSync(path.join(plugin, 'index.mjs'), 'export function apply() {}\n')
267
+ const out = path.join(dir, 'flow.json')
268
+ const run = spawnSync(process.execPath, [COLLECT, '--package', plugin, '--static', '--out', out], {
269
+ encoding: 'utf8', timeout: 120_000,
270
+ })
271
+ assert.equal(run.status, 1, `${run.stderr}\n${run.stdout}`)
272
+ for (const marker of ['FAIL J1.install.preflight', '1) 子检查:', '2) 命令:', '3) exitCode:', '4) stderr(末20行):', '5) 日志:', '条款锚: 32#R1']) {
273
+ assert.ok(run.stdout.includes(marker), `missing hint marker ${marker}\n${run.stdout}`)
274
+ }
275
+ const doc = JSON.parse(fs.readFileSync(out, 'utf8'))
276
+ assert.equal(doc.journeys.J1.status, 'fail')
277
+ assert.equal(doc.conclusion, 'flow-fail')
278
+ assert.equal(validateFlowEvidence(doc).valid, true)
279
+ } finally {
280
+ fs.rmSync(dir, { recursive: true, force: true })
281
+ }
282
+ })
283
+
284
+ test('flow-evidence: the published schema declares the validator contract', () => {
285
+ const schema = JSON.parse(fs.readFileSync(path.join(STD, 'spec', 'flow-evidence.schema.json'), 'utf8'))
286
+ assert.equal(schema.properties.schema.const, 'dsh-flow-evidence/2')
287
+ for (const key of ['schema', 'generatedAt', 'producer', 'subject', 'host', 'journeys', 'claims', 'chain']) {
288
+ assert.ok(schema.required.includes(key), `schema.required is missing ${key}`)
289
+ }
290
+ for (const id of FLOW_IDS) assert.ok(schema.properties.journeys.required.includes(id), `journeys.required is missing ${id}`)
291
+ for (const key of ['id', 'command', 'exitCode', 'startedAt', 'endedAt', 'stdout', 'assertions']) {
292
+ assert.ok(schema.$defs.check.required.includes(key), `check.required is missing ${key}`)
293
+ }
294
+ assert.ok(schema.$defs.journey.properties.checks)
295
+ })