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.
- package/40-checker/README.md +15 -0
- package/40-checker/bin/dsh-flow-collect.mjs +3 -0
- package/40-checker/bin/dsh-levels.mjs +13 -2
- package/40-checker/scripts/bundle-smoke.mjs +3 -0
- package/40-checker/scripts/entry18-ci.mjs +4 -3
- package/40-checker/scripts/entry18-web.mjs +4 -1
- package/40-checker/scripts/flow-collect.mjs +494 -0
- package/40-checker/scripts/flow-sample-ci.mjs +4 -4
- package/40-checker/scripts/host-farm.mjs +2 -1
- package/40-checker/scripts/journey-config-observe.mjs +5 -3
- package/40-checker/scripts/journey-runtime.mjs +208 -83
- package/40-checker/scripts/level-audit.mjs +91 -19
- package/40-checker/scripts/m2-runner.mjs +4 -3
- package/40-checker/scripts/runtime-child.mjs +4 -0
- package/40-checker/src/cli.mjs +10 -2
- package/40-checker/src/flow-evidence.mjs +431 -0
- package/40-checker/src/index.mjs +3 -0
- package/40-checker/src/remediation.mjs +132 -0
- package/40-checker/src/report.mjs +19 -2
- package/40-checker/src/sandbox-env.mjs +78 -0
- package/40-checker/test/flow-evidence.test.mjs +295 -0
- package/40-checker/test/remediation.test.mjs +300 -0
- package/CHANGELOG.md +20 -0
- package/bin/dsh-flow-collect.mjs +4 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- package/spec/flow/README.md +23 -2
- package/spec/flow-evidence.schema.json +217 -0
- package/spec/levels.json +2 -0
- package/spec/remediation.json +478 -0
|
@@ -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
|
-
|
|
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
|
+
})
|