dsh-standards 1.1.2 → 1.2.1

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 (42) 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/scripts/bundle-smoke.mjs +3 -0
  5. package/40-checker/scripts/entry18-ci.mjs +4 -3
  6. package/40-checker/scripts/entry18-web.mjs +4 -1
  7. package/40-checker/scripts/flow-collect.mjs +494 -0
  8. package/40-checker/scripts/flow-sample-ci.mjs +4 -4
  9. package/40-checker/scripts/host-farm.mjs +2 -1
  10. package/40-checker/scripts/journey-config-observe.mjs +5 -3
  11. package/40-checker/scripts/journey-runtime.mjs +208 -83
  12. package/40-checker/scripts/level-audit.mjs +119 -19
  13. package/40-checker/scripts/m2-runner.mjs +4 -3
  14. package/40-checker/scripts/runtime-child.mjs +4 -0
  15. package/40-checker/src/cli.mjs +10 -2
  16. package/40-checker/src/flow-evidence.mjs +455 -0
  17. package/40-checker/src/index.mjs +3 -0
  18. package/40-checker/src/remediation.mjs +137 -0
  19. package/40-checker/src/report.mjs +19 -2
  20. package/40-checker/src/sandbox-env.mjs +78 -0
  21. package/CHANGELOG.md +22 -0
  22. package/bin/dsh-flow-collect.mjs +4 -0
  23. package/package.json +1 -2
  24. package/spec/README.md +2 -0
  25. package/spec/flow/README.md +27 -2
  26. package/spec/flow-evidence.schema.json +221 -0
  27. package/spec/levels.json +2 -0
  28. package/spec/remediation.json +478 -0
  29. package/40-checker/test/attestation.test.mjs +0 -59
  30. package/40-checker/test/checks.test.mjs +0 -414
  31. package/40-checker/test/claim.test.mjs +0 -28
  32. package/40-checker/test/client-shape.test.mjs +0 -35
  33. package/40-checker/test/dco.test.mjs +0 -51
  34. package/40-checker/test/entry-validate.test.mjs +0 -172
  35. package/40-checker/test/governance.test.mjs +0 -76
  36. package/40-checker/test/label.test.mjs +0 -97
  37. package/40-checker/test/level-audit.test.mjs +0 -99
  38. package/40-checker/test/levels.test.mjs +0 -31
  39. package/40-checker/test/ops.test.mjs +0 -117
  40. package/40-checker/test/permissions.test.mjs +0 -40
  41. package/40-checker/test/semver.test.mjs +0 -32
  42. package/40-checker/test/verify.test.mjs +0 -57
@@ -5,6 +5,7 @@ import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
5
5
  import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
6
6
  import { runRuntimeChecks } from './runtime.mjs'
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
+ import { sanitizedEnv } from './sandbox-env.mjs'
8
9
  import { readSources } from './util.mjs'
9
10
  import { buildReport, writeReport } from './report.mjs'
10
11
 
@@ -40,6 +41,8 @@ Options:
40
41
  --web <file> optional web-app cordis.patch.yml for bundle smoke
41
42
  --bare <dir> optional bare-module base for bundle smoke (host farm)
42
43
  --out <file> write the report to a file (stdout otherwise)
44
+ --advice include remediation advice in the report (default: on)
45
+ --no-advice omit the additive advice[] field
43
46
  --help show this help
44
47
  --version show the checker version
45
48
 
@@ -75,6 +78,7 @@ function parseArgs(argv) {
75
78
  web: null,
76
79
  bare: null,
77
80
  out: null,
81
+ advice: true,
78
82
  help: false,
79
83
  version: false,
80
84
  }
@@ -117,6 +121,8 @@ function parseArgs(argv) {
117
121
  case '--base': options.base = value(); break
118
122
  case '--web': options.web = value(); break
119
123
  case '--bare': options.bare = value(); break
124
+ case '--advice': options.advice = inline === undefined ? true : inline !== 'false'; break
125
+ case '--no-advice': options.advice = false; break
120
126
  default: throw new Error(`unknown option: ${key}`)
121
127
  }
122
128
  }
@@ -152,7 +158,8 @@ function runtimeNotRun(reason) {
152
158
  function isolatedRuntimeChecks(input) {
153
159
  return new Promise((resolve) => {
154
160
  const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'runtime-child.mjs')
155
- const child = spawn(process.execPath, [script], { stdio: ['pipe', 'pipe', 'pipe'] })
161
+ // The child imports the inspected package: never inherit the caller's env.
162
+ const child = spawn(process.execPath, [script], { stdio: ['pipe', 'pipe', 'pipe'], env: sanitizedEnv() })
156
163
  let out = ''
157
164
  let err = ''
158
165
  const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, input.timeoutMs + 3000)
@@ -198,7 +205,7 @@ async function entryRunCheck(kind, runtimeChecks, options, dir) {
198
205
  if (options.base) args.push('--base', options.base)
199
206
  if (options.web) args.push('--web', options.web)
200
207
  if (options.bare) args.push('--bare', options.bare)
201
- const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: options.timeoutMs + 5000, maxBuffer: 8 * 1024 * 1024 })
208
+ const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: options.timeoutMs + 5000, maxBuffer: 8 * 1024 * 1024, env: sanitizedEnv() })
202
209
  if (run.error) return { ...base, result: 'ERROR', coverage: 'not-run', reason: `bundle smoke failed to start: ${run.error.message}` }
203
210
  let smoke
204
211
  try { smoke = JSON.parse(run.stdout) } catch { return { ...base, result: 'ERROR', coverage: 'not-run', reason: `bundle smoke produced no JSON: ${(run.stderr || '').slice(0, 300)}` } }
@@ -343,6 +350,7 @@ export async function main(argv = process.argv.slice(2)) {
343
350
  staticOnly: options.staticOnly,
344
351
  entryRuntime: options.entryRuntime,
345
352
  entryClaim: options.entryClaim,
353
+ advice: options.advice,
346
354
  })
347
355
  const output = writeReport(report, options.format)
348
356
  if (options.out) {
@@ -0,0 +1,455 @@
1
+ /**
2
+ * FLOW evidence chain: `dsh-flow-evidence/2` construction and validation.
3
+ *
4
+ * One module owns the shape so the collector and the audit cannot drift:
5
+ * - `treeSha256` binds the package directory (read-only copy stays identical);
6
+ * - `finalizeEvidence`/`evidenceHash` bind the whole document (chain);
7
+ * - `validateFlowEvidence` is the structural gate `level-audit` uses. v1
8
+ * (`dsh-flow-journey/1`, `dsh-flow-runtime/1`) and the bare
9
+ * `{ "J1": { "status": "pass" } }` shape are recognised only so the audit
10
+ * can label them `deprecated`; they never count as a pass.
11
+ *
12
+ * Pass rule (per journey, enforced from `checks[]`, never from the declared
13
+ * status alone): at least one check, every check has exitCode 0, at least one
14
+ * observed (non-static) check, and every observed check's assertions hold.
15
+ * A journey declared `warn`/`blocked`/`na`/`static`/`not-run` never counts.
16
+ *
17
+ * Runtime rule: passing journeys are necessary but not sufficient. Only
18
+ * sandbox-attested evidence (`claims.claimLevel === 'sandbox-attested'` or
19
+ * `producer.kind === 'sandbox'`) may be reported as `flow.state = 'runtime'`.
20
+ * `self-attested`/`ci-attested` documents keep their `passed` count but stay
21
+ * `not-run`, because structural validation cannot prove where commands ran.
22
+ */
23
+ import crypto from 'node:crypto'
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+
27
+ export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
28
+ export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
29
+ export const FLOW_V1_SCHEMAS = new Set(['dsh-flow-journey/1', 'dsh-flow-runtime/1'])
30
+ export const JOURNEY_STATUSES = new Set(['pass', 'fail', 'warn', 'blocked', 'static', 'na', 'not-run'])
31
+ export const CHECK_KINDS = new Set(['observed', 'static', 'manual'])
32
+
33
+ const HASH_RE = /^sha256:[0-9a-f]{64}$/
34
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
35
+ const COMMIT_RE = /^[0-9a-f]{40}$/
36
+ const INTEGRITY_RE = /^(?:sha512|sha256)-[A-Za-z0-9+/=]+$/
37
+
38
+ /** Journey -> normative anchor in 32-DSH-FLOW顺畅规范.md (R1..R8). */
39
+ export const FLOW_CLAUSE_ANCHORS = {
40
+ J1: '32#R1 安装/移除',
41
+ J2: '32#R2 启用/停用',
42
+ J3: '32#R3 配置',
43
+ J4: '32#R4 首次成功',
44
+ J5: '32#R5 权限',
45
+ J6: '32#R6 升级/回滚/卸载',
46
+ J7: '32#R7 可观测',
47
+ J8: '32#R8 client/server',
48
+ }
49
+
50
+ export function clauseAnchor(checkId) {
51
+ const id = String(checkId ?? '')
52
+ return FLOW_CLAUSE_ANCHORS[id.slice(0, 2)] ?? null
53
+ }
54
+
55
+ function isPlainObject(value) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
57
+ }
58
+
59
+ export function sha256Hex(data) {
60
+ return crypto.createHash('sha256').update(data).digest('hex')
61
+ }
62
+
63
+ export function sha256Value(data) {
64
+ return `sha256:${sha256Hex(data)}`
65
+ }
66
+
67
+ export function sha256File(file) {
68
+ return sha256Value(fs.readFileSync(file))
69
+ }
70
+
71
+ /** JSON with recursively sorted object keys; array order is preserved. */
72
+ export function canonicalJson(value) {
73
+ const sort = (node) => {
74
+ if (Array.isArray(node)) return node.map(sort)
75
+ if (isPlainObject(node)) {
76
+ const out = {}
77
+ for (const key of Object.keys(node).sort()) out[key] = sort(node[key])
78
+ return out
79
+ }
80
+ return node
81
+ }
82
+ return JSON.stringify(sort(value))
83
+ }
84
+
85
+ /** Chain hash: every field except `chain` and `signature`. */
86
+ export function evidenceHash(document) {
87
+ const copy = { ...document }
88
+ delete copy.chain
89
+ delete copy.signature
90
+ return sha256Value(canonicalJson(copy))
91
+ }
92
+
93
+ export function finalizeEvidence(document) {
94
+ const output = { ...document }
95
+ output.chain = { evidenceHash: evidenceHash(output) }
96
+ return output
97
+ }
98
+
99
+ export function evidenceHashMatches(document) {
100
+ const declared = document?.chain?.evidenceHash
101
+ if (typeof declared !== 'string') return false
102
+ return declared === evidenceHash(document)
103
+ }
104
+
105
+ const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
106
+ const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
107
+
108
+ /**
109
+ * Deterministic directory hash: relative path + byte length + file hash for
110
+ * every regular file, sorted, with `.git`/`node_modules` and `exclude` skipped.
111
+ * Symlinks are hashed by target string so re-linking cannot pass unnoticed.
112
+ * Returns `{ hash, files, bytes })` where `hash` is `sha256:<64 hex>`.
113
+ */
114
+ export function treeSha256(dir, { exclude = [] } = {}) {
115
+ const root = path.resolve(dir)
116
+ const skip = new Set([...DEFAULT_TREE_EXCLUDE, ...exclude.map(item => String(item).split(path.sep).join('/'))])
117
+ const rows = []
118
+ let bytes = 0
119
+ const walk = (current, relative) => {
120
+ const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
121
+ for (const entry of entries) {
122
+ const rel = relative ? `${relative}/${entry.name}` : entry.name
123
+ const segments = rel.split('/')
124
+ if (segments.some(segment => TREE_EXCLUDE_ALWAYS.has(segment))) continue
125
+ if ([...skip].some(pattern => rel === pattern || rel.startsWith(`${pattern}/`))) continue
126
+ const absolute = path.join(current, entry.name)
127
+ if (entry.isDirectory()) {
128
+ walk(absolute, rel)
129
+ } else if (entry.isSymbolicLink()) {
130
+ const target = fs.readlinkSync(absolute)
131
+ rows.push(`${rel}\0link\0${sha256Hex(target)}`)
132
+ } else if (entry.isFile()) {
133
+ const size = fs.statSync(absolute).size
134
+ bytes += size
135
+ rows.push(`${rel}\0${size}\0${sha256Hex(fs.readFileSync(absolute))}`)
136
+ }
137
+ }
138
+ }
139
+ walk(root, '')
140
+ rows.sort()
141
+ return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
142
+ }
143
+
144
+ export function isIsoTimestamp(value) {
145
+ return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
146
+ }
147
+
148
+ /**
149
+ * Aggregate one journey from its checks.
150
+ * `pass` requires observed evidence; `static` means preflight only; `warn`
151
+ * means the commands succeeded but an assertion contradicts the claim.
152
+ */
153
+ export function journeyAggregate(checks) {
154
+ if (!Array.isArray(checks) || checks.length === 0) return 'not-run'
155
+ if (checks.some(check => check?.exitCode !== 0)) return 'fail'
156
+ const observed = checks.filter(check => (check?.kind ?? 'observed') !== 'static')
157
+ if (observed.length === 0) return 'static'
158
+ const held = observed.every(check => Array.isArray(check.assertions)
159
+ && check.assertions.length > 0
160
+ && check.assertions.every(assertion => assertion?.observed === true))
161
+ return held ? 'pass' : 'warn'
162
+ }
163
+
164
+ /** True only when a declared journey status agrees with its checks. */
165
+ export function journeyCountsAsPass(journey) {
166
+ const derived = journeyAggregate(journey?.checks)
167
+ const declared = journey?.status ?? null
168
+ if (derived !== 'pass') return false
169
+ return declared === null || declared === 'pass'
170
+ }
171
+
172
+ /**
173
+ * Runtime eligibility. A local collector run is `self-attested`
174
+ * (`ci-attested` with `--ci`): structurally valid, but it does not prove that
175
+ * commands ran, let alone in a sandbox. Only `claims.claimLevel ===
176
+ * 'sandbox-attested'` or the producer-side equivalent `producer.kind ===
177
+ * 'sandbox'` may be reported as FLOW runtime.
178
+ */
179
+ export function isSandboxAttested(document) {
180
+ if (!isPlainObject(document)) return false
181
+ return document.claims?.claimLevel === 'sandbox-attested' || document.producer?.kind === 'sandbox'
182
+ }
183
+
184
+ function emptyVerdict(kind, schema, errors) {
185
+ return {
186
+ kind,
187
+ schema: schema ?? null,
188
+ valid: false,
189
+ errors,
190
+ journeys: {},
191
+ computedHash: null,
192
+ declaredHash: null,
193
+ subject: null,
194
+ host: null,
195
+ generatedAt: null,
196
+ conclusion: null,
197
+ claimLevel: null,
198
+ producerKind: null,
199
+ sandboxAttested: false,
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Classify + structurally validate a flow evidence document.
205
+ * `kind`: `v2` | `v1-deprecated` | `legacy` | `invalid`.
206
+ */
207
+ export function validateFlowEvidence(document) {
208
+ if (!isPlainObject(document)) return emptyVerdict('invalid', null, ['root must be a JSON object'])
209
+ const schema = typeof document.schema === 'string' ? document.schema : null
210
+ if (schema === FLOW_EVIDENCE_SCHEMA) return validateV2(document)
211
+ const legacyRows = FLOW_IDS.some(id => isPlainObject(document[id]))
212
+ if (schema && (FLOW_V1_SCHEMAS.has(schema) || !legacyRows)) {
213
+ return emptyVerdict('v1-deprecated', schema, [
214
+ `schema ${schema} is deprecated; only ${FLOW_EVIDENCE_SCHEMA} is accepted`,
215
+ ])
216
+ }
217
+ if (legacyRows || isPlainObject(document.journeys)) {
218
+ return emptyVerdict('legacy', schema, [
219
+ 'bare {Jx:{status}} rows are deprecated and never count as a pass',
220
+ ])
221
+ }
222
+ return emptyVerdict('invalid', schema, [`unrecognised flow evidence document; expected schema ${FLOW_EVIDENCE_SCHEMA}`])
223
+ }
224
+
225
+ function validateV2(document) {
226
+ const errors = []
227
+ const requireObject = (value, label) => {
228
+ if (!isPlainObject(value)) {
229
+ errors.push(`${label} must be an object`)
230
+ return false
231
+ }
232
+ return true
233
+ }
234
+ const requireString = (value, label, { allowNull = false, minLength = 1, pattern = null } = {}) => {
235
+ if (value === null && allowNull) return
236
+ if (typeof value !== 'string' || value.length < minLength) {
237
+ errors.push(`${label} must be a non-empty string${allowNull ? ' or null' : ''}`)
238
+ return
239
+ }
240
+ if (pattern && !pattern.test(value)) errors.push(`${label} has an invalid format`)
241
+ }
242
+ const requireIso = (value, label) => {
243
+ if (!isIsoTimestamp(value)) errors.push(`${label} must be an ISO-8601 timestamp`)
244
+ }
245
+ const requireHash = (value, label, { allowNull = false } = {}) => {
246
+ if (value === null && allowNull) return
247
+ if (typeof value !== 'string' || !HASH_RE.test(value)) errors.push(`${label} must be sha256:<64 lowercase hex>${allowNull ? ' or null' : ''}`)
248
+ }
249
+ const requireLog = (value, label) => {
250
+ if (!requireObject(value, label)) return
251
+ requireString(value.path, `${label}.path`)
252
+ requireHash(value.sha256, `${label}.sha256`)
253
+ for (const key of Object.keys(value)) if (!['path', 'sha256'].includes(key)) errors.push(`${label}.${key} is not allowed`)
254
+ }
255
+
256
+ for (const key of ['schema', 'generatedAt', 'producer', 'subject', 'host', 'journeys', 'claims', 'chain']) {
257
+ if (!(key in document)) errors.push(`${key} is required`)
258
+ }
259
+ if (!errors.includes('schema is required')) {
260
+ if (document.schema !== FLOW_EVIDENCE_SCHEMA) errors.push(`schema must be ${FLOW_EVIDENCE_SCHEMA}`)
261
+ }
262
+ if ('generatedAt' in document) requireIso(document.generatedAt, 'generatedAt')
263
+ if ('evidenceId' in document) requireString(document.evidenceId, 'evidenceId')
264
+
265
+ if (requireObject(document.producer, 'producer')) {
266
+ requireIso(document.producer.at ?? null, 'producer.at')
267
+ const command = document.producer.command
268
+ if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
269
+ errors.push('producer.command must be a non-empty array of strings')
270
+ }
271
+ if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox'].includes(document.producer.kind)) {
272
+ errors.push('producer.kind must be local, ci, or sandbox')
273
+ }
274
+ for (const key of ['runner', 'actor', 'runUrl']) {
275
+ if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
276
+ }
277
+ }
278
+
279
+ if (requireObject(document.subject, 'subject')) {
280
+ const subject = document.subject
281
+ requireString(subject.name, 'subject.name')
282
+ requireString(subject.version, 'subject.version')
283
+ requireFieldOrNull(errors, subject, 'integrity', value => requireString(value, 'subject.integrity', { allowNull: true, pattern: INTEGRITY_RE }))
284
+ requireField(errors, subject, 'tarballSha256', () => requireHash(subject.tarballSha256, 'subject.tarballSha256'))
285
+ requireFieldOrNull(errors, subject, 'commit', value => requireString(value, 'subject.commit', { allowNull: true, pattern: COMMIT_RE }))
286
+ requireField(errors, subject, 'treeSha256', () => requireHash(subject.treeSha256, 'subject.treeSha256'))
287
+ if ('treeSha256After' in subject) requireHash(subject.treeSha256After, 'subject.treeSha256After', { allowNull: true })
288
+ if ('treeStable' in subject && typeof subject.treeStable !== 'boolean') errors.push('subject.treeStable must be a boolean')
289
+ if ('treeExclude' in subject) {
290
+ if (!Array.isArray(subject.treeExclude) || subject.treeExclude.some(item => typeof item !== 'string')) {
291
+ errors.push('subject.treeExclude must be an array of strings')
292
+ }
293
+ }
294
+ if ('source' in subject) requireString(subject.source, 'subject.source')
295
+ }
296
+
297
+ if (requireObject(document.host, 'host')) {
298
+ requireString(document.host.dsh, 'host.dsh', { allowNull: true })
299
+ requireString(document.host.node, 'host.node')
300
+ requireString(document.host.platform, 'host.platform')
301
+ for (const key of ['dshCommit', 'cordis']) {
302
+ if (key in document.host && document.host[key] !== null) requireString(document.host[key], `host.${key}`)
303
+ }
304
+ }
305
+
306
+ if (requireObject(document.journeys, 'journeys')) {
307
+ for (const key of Object.keys(document.journeys)) {
308
+ if (!FLOW_IDS.includes(key)) errors.push(`journeys.${key} is not a known journey (J1-J8)`)
309
+ }
310
+ for (const id of FLOW_IDS) {
311
+ const journey = document.journeys[id]
312
+ if (!isPlainObject(journey)) {
313
+ errors.push(`journeys.${id} must be an object`)
314
+ continue
315
+ }
316
+ for (const key of Object.keys(journey)) {
317
+ if (!['status', 'reason', 'kind', 'checks', 'limitations'].includes(key)) errors.push(`journeys.${id}.${key} is not allowed`)
318
+ }
319
+ if ('status' in journey && journey.status !== null && !JOURNEY_STATUSES.has(journey.status)) {
320
+ errors.push(`journeys.${id}.status must be one of ${[...JOURNEY_STATUSES].join(', ')}`)
321
+ }
322
+ if ('reason' in journey && journey.reason !== null) requireString(journey.reason, `journeys.${id}.reason`)
323
+ if ('limitations' in journey) {
324
+ if (!Array.isArray(journey.limitations) || journey.limitations.some(item => typeof item !== 'string')) {
325
+ errors.push(`journeys.${id}.limitations must be an array of strings`)
326
+ }
327
+ }
328
+ if (!Array.isArray(journey.checks)) {
329
+ errors.push(`journeys.${id}.checks must be an array`)
330
+ continue
331
+ }
332
+ journey.checks.forEach((check, index) => validateCheck(check, `journeys.${id}.checks[${index}]`))
333
+ }
334
+ }
335
+
336
+ if (requireObject(document.claims, 'claims')) {
337
+ const claims = document.claims
338
+ if (!['self-attested', 'ci-attested', 'sandbox-attested'].includes(claims.claimLevel)) {
339
+ errors.push('claims.claimLevel must be self-attested, ci-attested, or sandbox-attested')
340
+ }
341
+ if ('notClaimed' in claims && (!Array.isArray(claims.notClaimed) || claims.notClaimed.some(item => typeof item !== 'string'))) {
342
+ errors.push('claims.notClaimed must be an array of strings')
343
+ }
344
+ if ('method' in claims) requireString(claims.method, 'claims.method')
345
+ }
346
+
347
+ if (requireObject(document.chain, 'chain')) {
348
+ requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
349
+ for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
350
+ }
351
+ if ('signature' in document && document.signature !== null && !isPlainObject(document.signature)) {
352
+ errors.push('signature must be null or an object')
353
+ }
354
+ if ('conclusion' in document && document.conclusion !== null && !['flow-pass', 'flow-warn', 'flow-blocked', 'flow-fail'].includes(document.conclusion)) {
355
+ errors.push('conclusion must be flow-pass, flow-warn, flow-blocked, or flow-fail')
356
+ }
357
+
358
+ // Aggregation consistency: declared pass/fail must agree with the checks.
359
+ const journeys = {}
360
+ for (const id of FLOW_IDS) {
361
+ const journey = isPlainObject(document.journeys?.[id]) ? document.journeys[id] : null
362
+ const checks = Array.isArray(journey?.checks) ? journey.checks : []
363
+ const derived = journeyAggregate(checks)
364
+ const declared = journey?.status ?? null
365
+ const passes = journeyCountsAsPass(journey)
366
+ journeys[id] = { declared, derived, checks: checks.length, passes }
367
+ if (declared === 'pass' && derived !== 'pass') errors.push(`journeys.${id} declares pass but its checks aggregate to ${derived}`)
368
+ if (declared === 'fail' && derived === 'pass') errors.push(`journeys.${id} declares fail but its checks aggregate to pass`)
369
+ }
370
+
371
+ // Chain integrity: recompute over every field except chain/signature.
372
+ const computedHash = evidenceHash(document)
373
+ const declaredHash = typeof document.chain?.evidenceHash === 'string' ? document.chain.evidenceHash : null
374
+ if (declaredHash !== computedHash) {
375
+ errors.push(`chain.evidenceHash does not match the document (declared ${declaredHash ?? 'missing'}, computed ${computedHash})`)
376
+ }
377
+
378
+ return {
379
+ kind: 'v2',
380
+ schema: FLOW_EVIDENCE_SCHEMA,
381
+ valid: errors.length === 0,
382
+ errors,
383
+ journeys,
384
+ computedHash,
385
+ declaredHash,
386
+ subject: isPlainObject(document.subject) ? document.subject : null,
387
+ host: isPlainObject(document.host) ? document.host : null,
388
+ generatedAt: document.generatedAt ?? null,
389
+ conclusion: document.conclusion ?? null,
390
+ claimLevel: isPlainObject(document.claims) ? document.claims.claimLevel ?? null : null,
391
+ producerKind: isPlainObject(document.producer) ? document.producer.kind ?? null : null,
392
+ sandboxAttested: isSandboxAttested(document),
393
+ }
394
+
395
+ function validateCheck(check, label) {
396
+ if (!isPlainObject(check)) {
397
+ errors.push(`${label} must be an object`)
398
+ return
399
+ }
400
+ for (const key of Object.keys(check)) {
401
+ if (!['id', 'kind', 'command', 'exitCode', 'startedAt', 'endedAt', 'stdout', 'stderr', 'assertions', 'durationMs'].includes(key)) {
402
+ errors.push(`${label}.${key} is not allowed`)
403
+ }
404
+ }
405
+ requireString(check.id, `${label}.id`, { pattern: /^J[1-8](?:\.[A-Za-z0-9._-]+)?$/ })
406
+ if ('kind' in check && !CHECK_KINDS.has(check.kind)) errors.push(`${label}.kind must be observed, static, or manual`)
407
+ if (!Array.isArray(check.command) || check.command.length === 0 || check.command.some(item => typeof item !== 'string')) {
408
+ errors.push(`${label}.command must be a non-empty array of strings`)
409
+ }
410
+ if (!Number.isInteger(check.exitCode) || check.exitCode < 0 || check.exitCode > 255) {
411
+ errors.push(`${label}.exitCode must be an integer in 0..255`)
412
+ }
413
+ requireIso(check.startedAt, `${label}.startedAt`)
414
+ requireIso(check.endedAt, `${label}.endedAt`)
415
+ if (isIsoTimestamp(check.startedAt) && isIsoTimestamp(check.endedAt) && Date.parse(check.endedAt) < Date.parse(check.startedAt)) {
416
+ errors.push(`${label}.endedAt is before startedAt`)
417
+ }
418
+ requireField(errors, check, 'stdout', () => requireLog(check.stdout, `${label}.stdout`))
419
+ if ('stderr' in check) requireLog(check.stderr, `${label}.stderr`)
420
+ if (!Array.isArray(check.assertions) || check.assertions.length === 0) {
421
+ errors.push(`${label}.assertions must be a non-empty array`)
422
+ } else {
423
+ check.assertions.forEach((assertion, index) => {
424
+ const alabel = `${label}.assertions[${index}]`
425
+ if (!isPlainObject(assertion)) {
426
+ errors.push(`${alabel} must be an object`)
427
+ return
428
+ }
429
+ for (const key of Object.keys(assertion)) if (!['claim', 'observed', 'detail'].includes(key)) errors.push(`${alabel}.${key} is not allowed`)
430
+ requireString(assertion.claim, `${alabel}.claim`)
431
+ if (typeof assertion.observed !== 'boolean') errors.push(`${alabel}.observed must be a boolean`)
432
+ if ('detail' in assertion && assertion.detail !== null) requireString(assertion.detail, `${alabel}.detail`)
433
+ })
434
+ }
435
+ if ('durationMs' in check && (typeof check.durationMs !== 'number' || check.durationMs < 0)) {
436
+ errors.push(`${label}.durationMs must be a non-negative number`)
437
+ }
438
+ }
439
+ }
440
+
441
+ function requireField(errors, object, key, validate) {
442
+ if (!(key in object)) {
443
+ errors.push(`${key} is required`)
444
+ return
445
+ }
446
+ validate()
447
+ }
448
+
449
+ function requireFieldOrNull(errors, object, key, validate) {
450
+ if (!(key in object)) {
451
+ errors.push(`${key} is required`)
452
+ return
453
+ }
454
+ validate(object[key])
455
+ }
@@ -4,5 +4,8 @@ export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind
4
4
  export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
5
5
  export { runRuntimeChecks } from './runtime.mjs'
6
6
  export { buildReport, formatText, formatSarif, writeReport } from './report.mjs'
7
+ export { joinAdvice, DOCS_BASE } from './remediation.mjs'
7
8
  export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
8
9
  export { parseInstallSpec } from './install-spec.mjs'
10
+ export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
11
+ export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Join a dsh-conformance/1 report with the remediation rule table.
3
+ *
4
+ * This module is deliberately pure: it never reads files, never writes, and
5
+ * never changes the report it receives. `buildReport()` calls it at the end so
6
+ * every report carries additive `advice[]` entries; the checker's verdict is
7
+ * untouched.
8
+ */
9
+
10
+ import packageJson from '../../package.json' with { type: 'json' }
11
+
12
+ /** Release-pinned guide base. `docs` in the rule table is a relative path. */
13
+ export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.4/'
14
+
15
+ const LIVE = new Set(['FAIL', 'WARN'])
16
+ const SEVERITY_RANK = { FAIL: 2, WARN: 1 }
17
+
18
+ function tableOf(rules) {
19
+ if (!rules || typeof rules !== 'object') return {}
20
+ // Accept either the whole spec/1 object or a bare key→rule map.
21
+ if (rules.rules && typeof rules.rules === 'object') return rules.rules
22
+ if (rules.items && typeof rules.items === 'object') return rules.items
23
+ return rules
24
+ }
25
+
26
+ function versionOf(rules) {
27
+ if (rules && typeof rules.version === 'string') return rules.version
28
+ return typeof packageJson.version === 'string' ? packageJson.version : null
29
+ }
30
+
31
+ function docsUrlOf(rule, docsBase) {
32
+ if (!rule || typeof rule.docs !== 'string' || rule.docs.trim() === '') return null
33
+ const base = String(docsBase ?? DOCS_BASE)
34
+ const prefix = base.endsWith('/') ? base : `${base}/`
35
+ return `${prefix}${rule.docs.replace(/^\/+/, '')}`
36
+ }
37
+
38
+ function uniqueEvidence(...lists) {
39
+ const seen = new Set()
40
+ const out = []
41
+ for (const list of lists) {
42
+ for (const value of Array.isArray(list) ? list : []) {
43
+ if (typeof value !== 'string' || value === '' || seen.has(value)) continue
44
+ seen.add(value)
45
+ out.push(value)
46
+ }
47
+ }
48
+ return out.slice(0, 10)
49
+ }
50
+
51
+ /**
52
+ * Build additive advice from a report.
53
+ *
54
+ * One entry is produced per (check.id, detail.item) pair with FAIL/WARN — or,
55
+ * for a FAIL/WARN check with no live details, one entry keyed by check.clause.
56
+ * `gate` is the check's final result (PASS/WARN/FAIL) after report adjustments
57
+ * such as the ENTRY static-claim downgrade; `severity` follows that gate, so a
58
+ * FAIL detail under a check downgraded to WARN yields a WARN advice entry. The
59
+ * detail's own reason stays in `reason` for diagnostics.
60
+ *
61
+ * Duplicate details are merged: evidence is unioned, occurrences counted, and
62
+ * the specific reasons are joined for diagnostics.
63
+ *
64
+ * @param {object} report dsh-conformance/1 report
65
+ * @param {object} rules flat key→rule map (also accepts a { rules } or { items } wrapper)
66
+ * @param {string} [docsBase] absolute release-pinned guide base
67
+ * @returns {object[]} advice entries
68
+ */
69
+ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
70
+ const table = tableOf(rules)
71
+ const version = versionOf(rules)
72
+ const merged = new Map()
73
+
74
+ const add = (check, key, gate, reason, evidence) => {
75
+ if (typeof key !== 'string' || key === '' || !LIVE.has(gate)) return false
76
+ const id = `${check.id}|${key}`
77
+ const current = merged.get(id)
78
+ if (!current) {
79
+ merged.set(id, {
80
+ check,
81
+ key,
82
+ gate,
83
+ reasons: typeof reason === 'string' && reason !== '' ? [reason] : [],
84
+ evidence: uniqueEvidence(evidence),
85
+ occurrences: 1,
86
+ })
87
+ return true
88
+ }
89
+ current.occurrences += 1
90
+ if (typeof reason === 'string' && reason !== '' && !current.reasons.includes(reason)) current.reasons.push(reason)
91
+ current.evidence = uniqueEvidence(current.evidence, evidence)
92
+ return true
93
+ }
94
+
95
+ for (const check of Array.isArray(report?.checks) ? report.checks : []) {
96
+ const liveDetails = (Array.isArray(check.details) ? check.details : [])
97
+ .filter(detail => detail && LIVE.has(detail.result))
98
+ let added = 0
99
+ for (const detail of liveDetails) if (add(check, detail.item, check.result, detail.reason, detail.evidence)) added += 1
100
+ // A runtime FAIL (DSC-04 / DSC-14) has no details; its clause is the only
101
+ // joinable key. Only use the fallback when no joinable detail was produced,
102
+ // so a specific detail always wins over the check-level summary.
103
+ if (added === 0 && LIVE.has(check.result)) {
104
+ add(check, check.clause, check.result, check.reason, check.evidence)
105
+ }
106
+ }
107
+
108
+ const advice = []
109
+ for (const entry of merged.values()) {
110
+ const rule = table[entry.key] ?? null
111
+ const item = {
112
+ key: entry.key,
113
+ gate: entry.gate,
114
+ severity: entry.gate,
115
+ title: rule?.title ?? entry.key,
116
+ why: rule?.why ?? null,
117
+ fix: rule?.fix ?? null,
118
+ docsUrl: docsUrlOf(rule, docsBase),
119
+ source: entry.check.id,
120
+ version,
121
+ evidence: entry.evidence,
122
+ reason: entry.reasons.slice(0, 3).join(' | '),
123
+ occurrences: entry.occurrences,
124
+ advisory: entry.check.advisory === true,
125
+ }
126
+ if (!rule) item.ruleMissing = true
127
+ advice.push(item)
128
+ }
129
+
130
+ // FAIL before WARN, then key/source for deterministic output.
131
+ advice.sort((a, b) => (
132
+ (SEVERITY_RANK[b.gate] ?? 0) - (SEVERITY_RANK[a.gate] ?? 0)
133
+ || a.key.localeCompare(b.key)
134
+ || a.source.localeCompare(b.source)
135
+ ))
136
+ return advice
137
+ }