dsh-standards 1.1.2 → 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.
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path'
2
+ import remediationSpec from '../../spec/remediation.json' with { type: 'json' }
3
+ import { DOCS_BASE, joinAdvice } from './remediation.mjs'
2
4
 
3
5
  export const PACKAGE_MANDATORY = [
4
6
  'DSC-01', 'DSC-02', 'DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09', 'DSC-11', 'DSC-12', 'DSC-13',
@@ -37,7 +39,7 @@ function allowedNa(check, scope, profile, kind) {
37
39
  return false
38
40
  }
39
41
 
40
- export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime' }) {
42
+ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true }) {
41
43
  const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
42
44
  const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
43
45
  const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
@@ -85,7 +87,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
85
87
  if (warnings.length > 0) reasonParts.push(`WARN: ${warnings.map(item => item.id).join(', ')}`)
86
88
  if (advisoryWarnings.length > 0) reasonParts.push(`advisory: ${advisoryWarnings.map(item => item.id).join(', ')}`)
87
89
 
88
- return {
90
+ const report = {
89
91
  schema: 'dsh-conformance/1',
90
92
  standard: { id: 'DS-C', version: '1.0.0' },
91
93
  subject,
@@ -120,6 +122,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
120
122
  reason: reasonParts.join('; '),
121
123
  },
122
124
  }
125
+ // Additive only: `advice` is a sibling of `checks`, never part of a check,
126
+ // and the verdict is computed above without reading it.
127
+ if (advice !== false) {
128
+ report.advice = joinAdvice(report, remediationSpec, DOCS_BASE)
129
+ }
130
+ return report
123
131
  }
124
132
 
125
133
  const ICON = { PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL', NA: 'NA ', ERROR: 'ERR ' }
@@ -145,6 +153,15 @@ export function formatText(report) {
145
153
  lines.push(`coverage: mandatory=${coverage.mandatory} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun}`)
146
154
  if (conclusion.reason) lines.push(`reason: ${conclusion.reason}`)
147
155
  if (conclusion.status === 'incomplete') lines.push('NOT A DS-C PASS: at least one mandatory check did not run (see notRun).')
156
+ if (Array.isArray(report.advice) && report.advice.length > 0) {
157
+ lines.push('')
158
+ lines.push('advice (non-blocking):')
159
+ for (const item of report.advice) {
160
+ const missing = item.ruleMissing ? ' [ruleMissing]' : ''
161
+ lines.push(` ${String(item.severity).padEnd(4)} ${String(item.key).padEnd(7)} ${item.source}${missing}: ${String(item.fix ?? item.title ?? '').replace(/\s+/g, ' ').slice(0, 160)}`)
162
+ if (item.docsUrl) lines.push(` ${item.docsUrl}`)
163
+ }
164
+ }
148
165
  lines.push('')
149
166
  return `${lines.join('\n')}\n`
150
167
  }
@@ -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
+ }
@@ -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
+ })