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
package/40-checker/src/cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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,431 @@
|
|
|
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
|
+
import crypto from 'node:crypto'
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
|
|
21
|
+
export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
|
|
22
|
+
export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
|
|
23
|
+
export const FLOW_V1_SCHEMAS = new Set(['dsh-flow-journey/1', 'dsh-flow-runtime/1'])
|
|
24
|
+
export const JOURNEY_STATUSES = new Set(['pass', 'fail', 'warn', 'blocked', 'static', 'na', 'not-run'])
|
|
25
|
+
export const CHECK_KINDS = new Set(['observed', 'static', 'manual'])
|
|
26
|
+
|
|
27
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/
|
|
28
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
29
|
+
const COMMIT_RE = /^[0-9a-f]{40}$/
|
|
30
|
+
const INTEGRITY_RE = /^(?:sha512|sha256)-[A-Za-z0-9+/=]+$/
|
|
31
|
+
|
|
32
|
+
/** Journey -> normative anchor in 32-DSH-FLOW顺畅规范.md (R1..R8). */
|
|
33
|
+
export const FLOW_CLAUSE_ANCHORS = {
|
|
34
|
+
J1: '32#R1 安装/移除',
|
|
35
|
+
J2: '32#R2 启用/停用',
|
|
36
|
+
J3: '32#R3 配置',
|
|
37
|
+
J4: '32#R4 首次成功',
|
|
38
|
+
J5: '32#R5 权限',
|
|
39
|
+
J6: '32#R6 升级/回滚/卸载',
|
|
40
|
+
J7: '32#R7 可观测',
|
|
41
|
+
J8: '32#R8 client/server',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clauseAnchor(checkId) {
|
|
45
|
+
const id = String(checkId ?? '')
|
|
46
|
+
return FLOW_CLAUSE_ANCHORS[id.slice(0, 2)] ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sha256Hex(data) {
|
|
54
|
+
return crypto.createHash('sha256').update(data).digest('hex')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sha256Value(data) {
|
|
58
|
+
return `sha256:${sha256Hex(data)}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sha256File(file) {
|
|
62
|
+
return sha256Value(fs.readFileSync(file))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** JSON with recursively sorted object keys; array order is preserved. */
|
|
66
|
+
export function canonicalJson(value) {
|
|
67
|
+
const sort = (node) => {
|
|
68
|
+
if (Array.isArray(node)) return node.map(sort)
|
|
69
|
+
if (isPlainObject(node)) {
|
|
70
|
+
const out = {}
|
|
71
|
+
for (const key of Object.keys(node).sort()) out[key] = sort(node[key])
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
return node
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(sort(value))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Chain hash: every field except `chain` and `signature`. */
|
|
80
|
+
export function evidenceHash(document) {
|
|
81
|
+
const copy = { ...document }
|
|
82
|
+
delete copy.chain
|
|
83
|
+
delete copy.signature
|
|
84
|
+
return sha256Value(canonicalJson(copy))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function finalizeEvidence(document) {
|
|
88
|
+
const output = { ...document }
|
|
89
|
+
output.chain = { evidenceHash: evidenceHash(output) }
|
|
90
|
+
return output
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function evidenceHashMatches(document) {
|
|
94
|
+
const declared = document?.chain?.evidenceHash
|
|
95
|
+
if (typeof declared !== 'string') return false
|
|
96
|
+
return declared === evidenceHash(document)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
|
|
100
|
+
const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Deterministic directory hash: relative path + byte length + file hash for
|
|
104
|
+
* every regular file, sorted, with `.git`/`node_modules` and `exclude` skipped.
|
|
105
|
+
* Symlinks are hashed by target string so re-linking cannot pass unnoticed.
|
|
106
|
+
* Returns `{ hash, files, bytes })` where `hash` is `sha256:<64 hex>`.
|
|
107
|
+
*/
|
|
108
|
+
export function treeSha256(dir, { exclude = [] } = {}) {
|
|
109
|
+
const root = path.resolve(dir)
|
|
110
|
+
const skip = new Set([...DEFAULT_TREE_EXCLUDE, ...exclude.map(item => String(item).split(path.sep).join('/'))])
|
|
111
|
+
const rows = []
|
|
112
|
+
let bytes = 0
|
|
113
|
+
const walk = (current, relative) => {
|
|
114
|
+
const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const rel = relative ? `${relative}/${entry.name}` : entry.name
|
|
117
|
+
const segments = rel.split('/')
|
|
118
|
+
if (segments.some(segment => TREE_EXCLUDE_ALWAYS.has(segment))) continue
|
|
119
|
+
if ([...skip].some(pattern => rel === pattern || rel.startsWith(`${pattern}/`))) continue
|
|
120
|
+
const absolute = path.join(current, entry.name)
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
walk(absolute, rel)
|
|
123
|
+
} else if (entry.isSymbolicLink()) {
|
|
124
|
+
const target = fs.readlinkSync(absolute)
|
|
125
|
+
rows.push(`${rel}\0link\0${sha256Hex(target)}`)
|
|
126
|
+
} else if (entry.isFile()) {
|
|
127
|
+
const size = fs.statSync(absolute).size
|
|
128
|
+
bytes += size
|
|
129
|
+
rows.push(`${rel}\0${size}\0${sha256Hex(fs.readFileSync(absolute))}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
walk(root, '')
|
|
134
|
+
rows.sort()
|
|
135
|
+
return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isIsoTimestamp(value) {
|
|
139
|
+
return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Aggregate one journey from its checks.
|
|
144
|
+
* `pass` requires observed evidence; `static` means preflight only; `warn`
|
|
145
|
+
* means the commands succeeded but an assertion contradicts the claim.
|
|
146
|
+
*/
|
|
147
|
+
export function journeyAggregate(checks) {
|
|
148
|
+
if (!Array.isArray(checks) || checks.length === 0) return 'not-run'
|
|
149
|
+
if (checks.some(check => check?.exitCode !== 0)) return 'fail'
|
|
150
|
+
const observed = checks.filter(check => (check?.kind ?? 'observed') !== 'static')
|
|
151
|
+
if (observed.length === 0) return 'static'
|
|
152
|
+
const held = observed.every(check => Array.isArray(check.assertions)
|
|
153
|
+
&& check.assertions.length > 0
|
|
154
|
+
&& check.assertions.every(assertion => assertion?.observed === true))
|
|
155
|
+
return held ? 'pass' : 'warn'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** True only when a declared journey status agrees with its checks. */
|
|
159
|
+
export function journeyCountsAsPass(journey) {
|
|
160
|
+
const derived = journeyAggregate(journey?.checks)
|
|
161
|
+
const declared = journey?.status ?? null
|
|
162
|
+
if (derived !== 'pass') return false
|
|
163
|
+
return declared === null || declared === 'pass'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function emptyVerdict(kind, schema, errors) {
|
|
167
|
+
return {
|
|
168
|
+
kind,
|
|
169
|
+
schema: schema ?? null,
|
|
170
|
+
valid: false,
|
|
171
|
+
errors,
|
|
172
|
+
journeys: {},
|
|
173
|
+
computedHash: null,
|
|
174
|
+
declaredHash: null,
|
|
175
|
+
subject: null,
|
|
176
|
+
host: null,
|
|
177
|
+
generatedAt: null,
|
|
178
|
+
conclusion: null,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Classify + structurally validate a flow evidence document.
|
|
184
|
+
* `kind`: `v2` | `v1-deprecated` | `legacy` | `invalid`.
|
|
185
|
+
*/
|
|
186
|
+
export function validateFlowEvidence(document) {
|
|
187
|
+
if (!isPlainObject(document)) return emptyVerdict('invalid', null, ['root must be a JSON object'])
|
|
188
|
+
const schema = typeof document.schema === 'string' ? document.schema : null
|
|
189
|
+
if (schema === FLOW_EVIDENCE_SCHEMA) return validateV2(document)
|
|
190
|
+
const legacyRows = FLOW_IDS.some(id => isPlainObject(document[id]))
|
|
191
|
+
if (schema && (FLOW_V1_SCHEMAS.has(schema) || !legacyRows)) {
|
|
192
|
+
return emptyVerdict('v1-deprecated', schema, [
|
|
193
|
+
`schema ${schema} is deprecated; only ${FLOW_EVIDENCE_SCHEMA} is accepted`,
|
|
194
|
+
])
|
|
195
|
+
}
|
|
196
|
+
if (legacyRows || isPlainObject(document.journeys)) {
|
|
197
|
+
return emptyVerdict('legacy', schema, [
|
|
198
|
+
'bare {Jx:{status}} rows are deprecated and never count as a pass',
|
|
199
|
+
])
|
|
200
|
+
}
|
|
201
|
+
return emptyVerdict('invalid', schema, [`unrecognised flow evidence document; expected schema ${FLOW_EVIDENCE_SCHEMA}`])
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateV2(document) {
|
|
205
|
+
const errors = []
|
|
206
|
+
const requireObject = (value, label) => {
|
|
207
|
+
if (!isPlainObject(value)) {
|
|
208
|
+
errors.push(`${label} must be an object`)
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
const requireString = (value, label, { allowNull = false, minLength = 1, pattern = null } = {}) => {
|
|
214
|
+
if (value === null && allowNull) return
|
|
215
|
+
if (typeof value !== 'string' || value.length < minLength) {
|
|
216
|
+
errors.push(`${label} must be a non-empty string${allowNull ? ' or null' : ''}`)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (pattern && !pattern.test(value)) errors.push(`${label} has an invalid format`)
|
|
220
|
+
}
|
|
221
|
+
const requireIso = (value, label) => {
|
|
222
|
+
if (!isIsoTimestamp(value)) errors.push(`${label} must be an ISO-8601 timestamp`)
|
|
223
|
+
}
|
|
224
|
+
const requireHash = (value, label, { allowNull = false } = {}) => {
|
|
225
|
+
if (value === null && allowNull) return
|
|
226
|
+
if (typeof value !== 'string' || !HASH_RE.test(value)) errors.push(`${label} must be sha256:<64 lowercase hex>${allowNull ? ' or null' : ''}`)
|
|
227
|
+
}
|
|
228
|
+
const requireLog = (value, label) => {
|
|
229
|
+
if (!requireObject(value, label)) return
|
|
230
|
+
requireString(value.path, `${label}.path`)
|
|
231
|
+
requireHash(value.sha256, `${label}.sha256`)
|
|
232
|
+
for (const key of Object.keys(value)) if (!['path', 'sha256'].includes(key)) errors.push(`${label}.${key} is not allowed`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const key of ['schema', 'generatedAt', 'producer', 'subject', 'host', 'journeys', 'claims', 'chain']) {
|
|
236
|
+
if (!(key in document)) errors.push(`${key} is required`)
|
|
237
|
+
}
|
|
238
|
+
if (!errors.includes('schema is required')) {
|
|
239
|
+
if (document.schema !== FLOW_EVIDENCE_SCHEMA) errors.push(`schema must be ${FLOW_EVIDENCE_SCHEMA}`)
|
|
240
|
+
}
|
|
241
|
+
if ('generatedAt' in document) requireIso(document.generatedAt, 'generatedAt')
|
|
242
|
+
if ('evidenceId' in document) requireString(document.evidenceId, 'evidenceId')
|
|
243
|
+
|
|
244
|
+
if (requireObject(document.producer, 'producer')) {
|
|
245
|
+
requireIso(document.producer.at ?? null, 'producer.at')
|
|
246
|
+
const command = document.producer.command
|
|
247
|
+
if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
|
|
248
|
+
errors.push('producer.command must be a non-empty array of strings')
|
|
249
|
+
}
|
|
250
|
+
if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox'].includes(document.producer.kind)) {
|
|
251
|
+
errors.push('producer.kind must be local, ci, or sandbox')
|
|
252
|
+
}
|
|
253
|
+
for (const key of ['runner', 'actor', 'runUrl']) {
|
|
254
|
+
if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (requireObject(document.subject, 'subject')) {
|
|
259
|
+
const subject = document.subject
|
|
260
|
+
requireString(subject.name, 'subject.name')
|
|
261
|
+
requireString(subject.version, 'subject.version')
|
|
262
|
+
requireFieldOrNull(errors, subject, 'integrity', value => requireString(value, 'subject.integrity', { allowNull: true, pattern: INTEGRITY_RE }))
|
|
263
|
+
requireField(errors, subject, 'tarballSha256', () => requireHash(subject.tarballSha256, 'subject.tarballSha256'))
|
|
264
|
+
requireFieldOrNull(errors, subject, 'commit', value => requireString(value, 'subject.commit', { allowNull: true, pattern: COMMIT_RE }))
|
|
265
|
+
requireField(errors, subject, 'treeSha256', () => requireHash(subject.treeSha256, 'subject.treeSha256'))
|
|
266
|
+
if ('treeSha256After' in subject) requireHash(subject.treeSha256After, 'subject.treeSha256After', { allowNull: true })
|
|
267
|
+
if ('treeStable' in subject && typeof subject.treeStable !== 'boolean') errors.push('subject.treeStable must be a boolean')
|
|
268
|
+
if ('treeExclude' in subject) {
|
|
269
|
+
if (!Array.isArray(subject.treeExclude) || subject.treeExclude.some(item => typeof item !== 'string')) {
|
|
270
|
+
errors.push('subject.treeExclude must be an array of strings')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if ('source' in subject) requireString(subject.source, 'subject.source')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (requireObject(document.host, 'host')) {
|
|
277
|
+
requireString(document.host.dsh, 'host.dsh', { allowNull: true })
|
|
278
|
+
requireString(document.host.node, 'host.node')
|
|
279
|
+
requireString(document.host.platform, 'host.platform')
|
|
280
|
+
for (const key of ['dshCommit', 'cordis']) {
|
|
281
|
+
if (key in document.host && document.host[key] !== null) requireString(document.host[key], `host.${key}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (requireObject(document.journeys, 'journeys')) {
|
|
286
|
+
for (const key of Object.keys(document.journeys)) {
|
|
287
|
+
if (!FLOW_IDS.includes(key)) errors.push(`journeys.${key} is not a known journey (J1-J8)`)
|
|
288
|
+
}
|
|
289
|
+
for (const id of FLOW_IDS) {
|
|
290
|
+
const journey = document.journeys[id]
|
|
291
|
+
if (!isPlainObject(journey)) {
|
|
292
|
+
errors.push(`journeys.${id} must be an object`)
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
for (const key of Object.keys(journey)) {
|
|
296
|
+
if (!['status', 'reason', 'kind', 'checks', 'limitations'].includes(key)) errors.push(`journeys.${id}.${key} is not allowed`)
|
|
297
|
+
}
|
|
298
|
+
if ('status' in journey && journey.status !== null && !JOURNEY_STATUSES.has(journey.status)) {
|
|
299
|
+
errors.push(`journeys.${id}.status must be one of ${[...JOURNEY_STATUSES].join(', ')}`)
|
|
300
|
+
}
|
|
301
|
+
if ('reason' in journey && journey.reason !== null) requireString(journey.reason, `journeys.${id}.reason`)
|
|
302
|
+
if ('limitations' in journey) {
|
|
303
|
+
if (!Array.isArray(journey.limitations) || journey.limitations.some(item => typeof item !== 'string')) {
|
|
304
|
+
errors.push(`journeys.${id}.limitations must be an array of strings`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!Array.isArray(journey.checks)) {
|
|
308
|
+
errors.push(`journeys.${id}.checks must be an array`)
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
journey.checks.forEach((check, index) => validateCheck(check, `journeys.${id}.checks[${index}]`))
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (requireObject(document.claims, 'claims')) {
|
|
316
|
+
const claims = document.claims
|
|
317
|
+
if (!['self-attested', 'ci-attested', 'sandbox-attested'].includes(claims.claimLevel)) {
|
|
318
|
+
errors.push('claims.claimLevel must be self-attested, ci-attested, or sandbox-attested')
|
|
319
|
+
}
|
|
320
|
+
if ('notClaimed' in claims && (!Array.isArray(claims.notClaimed) || claims.notClaimed.some(item => typeof item !== 'string'))) {
|
|
321
|
+
errors.push('claims.notClaimed must be an array of strings')
|
|
322
|
+
}
|
|
323
|
+
if ('method' in claims) requireString(claims.method, 'claims.method')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (requireObject(document.chain, 'chain')) {
|
|
327
|
+
requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
|
|
328
|
+
for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
|
|
329
|
+
}
|
|
330
|
+
if ('signature' in document && document.signature !== null && !isPlainObject(document.signature)) {
|
|
331
|
+
errors.push('signature must be null or an object')
|
|
332
|
+
}
|
|
333
|
+
if ('conclusion' in document && document.conclusion !== null && !['flow-pass', 'flow-warn', 'flow-blocked', 'flow-fail'].includes(document.conclusion)) {
|
|
334
|
+
errors.push('conclusion must be flow-pass, flow-warn, flow-blocked, or flow-fail')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Aggregation consistency: declared pass/fail must agree with the checks.
|
|
338
|
+
const journeys = {}
|
|
339
|
+
for (const id of FLOW_IDS) {
|
|
340
|
+
const journey = isPlainObject(document.journeys?.[id]) ? document.journeys[id] : null
|
|
341
|
+
const checks = Array.isArray(journey?.checks) ? journey.checks : []
|
|
342
|
+
const derived = journeyAggregate(checks)
|
|
343
|
+
const declared = journey?.status ?? null
|
|
344
|
+
const passes = journeyCountsAsPass(journey)
|
|
345
|
+
journeys[id] = { declared, derived, checks: checks.length, passes }
|
|
346
|
+
if (declared === 'pass' && derived !== 'pass') errors.push(`journeys.${id} declares pass but its checks aggregate to ${derived}`)
|
|
347
|
+
if (declared === 'fail' && derived === 'pass') errors.push(`journeys.${id} declares fail but its checks aggregate to pass`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Chain integrity: recompute over every field except chain/signature.
|
|
351
|
+
const computedHash = evidenceHash(document)
|
|
352
|
+
const declaredHash = typeof document.chain?.evidenceHash === 'string' ? document.chain.evidenceHash : null
|
|
353
|
+
if (declaredHash !== computedHash) {
|
|
354
|
+
errors.push(`chain.evidenceHash does not match the document (declared ${declaredHash ?? 'missing'}, computed ${computedHash})`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
kind: 'v2',
|
|
359
|
+
schema: FLOW_EVIDENCE_SCHEMA,
|
|
360
|
+
valid: errors.length === 0,
|
|
361
|
+
errors,
|
|
362
|
+
journeys,
|
|
363
|
+
computedHash,
|
|
364
|
+
declaredHash,
|
|
365
|
+
subject: isPlainObject(document.subject) ? document.subject : null,
|
|
366
|
+
host: isPlainObject(document.host) ? document.host : null,
|
|
367
|
+
generatedAt: document.generatedAt ?? null,
|
|
368
|
+
conclusion: document.conclusion ?? null,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function validateCheck(check, label) {
|
|
372
|
+
if (!isPlainObject(check)) {
|
|
373
|
+
errors.push(`${label} must be an object`)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
for (const key of Object.keys(check)) {
|
|
377
|
+
if (!['id', 'kind', 'command', 'exitCode', 'startedAt', 'endedAt', 'stdout', 'stderr', 'assertions', 'durationMs'].includes(key)) {
|
|
378
|
+
errors.push(`${label}.${key} is not allowed`)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
requireString(check.id, `${label}.id`, { pattern: /^J[1-8](?:\.[A-Za-z0-9._-]+)?$/ })
|
|
382
|
+
if ('kind' in check && !CHECK_KINDS.has(check.kind)) errors.push(`${label}.kind must be observed, static, or manual`)
|
|
383
|
+
if (!Array.isArray(check.command) || check.command.length === 0 || check.command.some(item => typeof item !== 'string')) {
|
|
384
|
+
errors.push(`${label}.command must be a non-empty array of strings`)
|
|
385
|
+
}
|
|
386
|
+
if (!Number.isInteger(check.exitCode) || check.exitCode < 0 || check.exitCode > 255) {
|
|
387
|
+
errors.push(`${label}.exitCode must be an integer in 0..255`)
|
|
388
|
+
}
|
|
389
|
+
requireIso(check.startedAt, `${label}.startedAt`)
|
|
390
|
+
requireIso(check.endedAt, `${label}.endedAt`)
|
|
391
|
+
if (isIsoTimestamp(check.startedAt) && isIsoTimestamp(check.endedAt) && Date.parse(check.endedAt) < Date.parse(check.startedAt)) {
|
|
392
|
+
errors.push(`${label}.endedAt is before startedAt`)
|
|
393
|
+
}
|
|
394
|
+
requireField(errors, check, 'stdout', () => requireLog(check.stdout, `${label}.stdout`))
|
|
395
|
+
if ('stderr' in check) requireLog(check.stderr, `${label}.stderr`)
|
|
396
|
+
if (!Array.isArray(check.assertions) || check.assertions.length === 0) {
|
|
397
|
+
errors.push(`${label}.assertions must be a non-empty array`)
|
|
398
|
+
} else {
|
|
399
|
+
check.assertions.forEach((assertion, index) => {
|
|
400
|
+
const alabel = `${label}.assertions[${index}]`
|
|
401
|
+
if (!isPlainObject(assertion)) {
|
|
402
|
+
errors.push(`${alabel} must be an object`)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
for (const key of Object.keys(assertion)) if (!['claim', 'observed', 'detail'].includes(key)) errors.push(`${alabel}.${key} is not allowed`)
|
|
406
|
+
requireString(assertion.claim, `${alabel}.claim`)
|
|
407
|
+
if (typeof assertion.observed !== 'boolean') errors.push(`${alabel}.observed must be a boolean`)
|
|
408
|
+
if ('detail' in assertion && assertion.detail !== null) requireString(assertion.detail, `${alabel}.detail`)
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
if ('durationMs' in check && (typeof check.durationMs !== 'number' || check.durationMs < 0)) {
|
|
412
|
+
errors.push(`${label}.durationMs must be a non-negative number`)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function requireField(errors, object, key, validate) {
|
|
418
|
+
if (!(key in object)) {
|
|
419
|
+
errors.push(`${key} is required`)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
validate()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function requireFieldOrNull(errors, object, key, validate) {
|
|
426
|
+
if (!(key in object)) {
|
|
427
|
+
errors.push(`${key} is required`)
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
validate(object[key])
|
|
431
|
+
}
|
package/40-checker/src/index.mjs
CHANGED
|
@@ -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, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
|
|
11
|
+
export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
* Duplicate details are merged: severity takes the worst value, evidence is
|
|
57
|
+
* unioned, and the specific reasons are joined for diagnostics.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} report dsh-conformance/1 report
|
|
60
|
+
* @param {object} rules flat key→rule map (also accepts a { rules } or { items } wrapper)
|
|
61
|
+
* @param {string} [docsBase] absolute release-pinned guide base
|
|
62
|
+
* @returns {object[]} advice entries
|
|
63
|
+
*/
|
|
64
|
+
export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
|
|
65
|
+
const table = tableOf(rules)
|
|
66
|
+
const version = versionOf(rules)
|
|
67
|
+
const merged = new Map()
|
|
68
|
+
|
|
69
|
+
const add = (check, key, severity, reason, evidence) => {
|
|
70
|
+
if (typeof key !== 'string' || key === '' || !LIVE.has(severity)) return false
|
|
71
|
+
const id = `${check.id}|${key}`
|
|
72
|
+
const current = merged.get(id)
|
|
73
|
+
if (!current) {
|
|
74
|
+
merged.set(id, {
|
|
75
|
+
check,
|
|
76
|
+
key,
|
|
77
|
+
severity,
|
|
78
|
+
reasons: typeof reason === 'string' && reason !== '' ? [reason] : [],
|
|
79
|
+
evidence: uniqueEvidence(evidence),
|
|
80
|
+
occurrences: 1,
|
|
81
|
+
})
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
current.occurrences += 1
|
|
85
|
+
if (typeof reason === 'string' && reason !== '' && !current.reasons.includes(reason)) current.reasons.push(reason)
|
|
86
|
+
if (SEVERITY_RANK[severity] > SEVERITY_RANK[current.severity]) current.severity = severity
|
|
87
|
+
current.evidence = uniqueEvidence(current.evidence, evidence)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const check of Array.isArray(report?.checks) ? report.checks : []) {
|
|
92
|
+
const liveDetails = (Array.isArray(check.details) ? check.details : [])
|
|
93
|
+
.filter(detail => detail && LIVE.has(detail.result))
|
|
94
|
+
let added = 0
|
|
95
|
+
for (const detail of liveDetails) if (add(check, detail.item, detail.result, detail.reason, detail.evidence)) added += 1
|
|
96
|
+
// A runtime FAIL (DSC-04 / DSC-14) has no details; its clause is the only
|
|
97
|
+
// joinable key. Only use the fallback when no joinable detail was produced,
|
|
98
|
+
// so a specific detail always wins over the check-level summary.
|
|
99
|
+
if (added === 0 && LIVE.has(check.result)) {
|
|
100
|
+
add(check, check.clause, check.result, check.reason, check.evidence)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const advice = []
|
|
105
|
+
for (const entry of merged.values()) {
|
|
106
|
+
const rule = table[entry.key] ?? null
|
|
107
|
+
const item = {
|
|
108
|
+
key: entry.key,
|
|
109
|
+
severity: entry.severity,
|
|
110
|
+
title: rule?.title ?? entry.key,
|
|
111
|
+
why: rule?.why ?? null,
|
|
112
|
+
fix: rule?.fix ?? null,
|
|
113
|
+
docsUrl: docsUrlOf(rule, docsBase),
|
|
114
|
+
source: entry.check.id,
|
|
115
|
+
version,
|
|
116
|
+
evidence: entry.evidence,
|
|
117
|
+
reason: entry.reasons.slice(0, 3).join(' | '),
|
|
118
|
+
occurrences: entry.occurrences,
|
|
119
|
+
advisory: entry.check.advisory === true,
|
|
120
|
+
}
|
|
121
|
+
if (!rule) item.ruleMissing = true
|
|
122
|
+
advice.push(item)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// FAIL before WARN, then key/source for deterministic output.
|
|
126
|
+
advice.sort((a, b) => (
|
|
127
|
+
(SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)
|
|
128
|
+
|| a.key.localeCompare(b.key)
|
|
129
|
+
|| a.source.localeCompare(b.source)
|
|
130
|
+
))
|
|
131
|
+
return advice
|
|
132
|
+
}
|