dsh-standards 1.2.1 → 1.3.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 +25 -3
- package/40-checker/bin/dsh-levels.mjs +7 -1
- package/40-checker/scripts/build-offline-kit.mjs +284 -0
- package/40-checker/scripts/build-sidecar.mjs +484 -0
- package/40-checker/scripts/flow-collect.mjs +147 -22
- package/40-checker/scripts/level-audit.mjs +57 -9
- package/40-checker/scripts/run-tests.mjs +17 -0
- package/40-checker/src/author-cli.mjs +139 -0
- package/40-checker/src/author-report.mjs +260 -0
- package/40-checker/src/cli.mjs +73 -8
- package/40-checker/src/flow-evidence.mjs +189 -3
- package/40-checker/src/index.mjs +3 -3
- package/40-checker/src/report.mjs +125 -24
- package/40-checker/src/sandbox-run.mjs +403 -0
- package/CHANGELOG.md +26 -0
- package/README.en.md +352 -0
- package/README.es.md +352 -0
- package/README.hi.md +352 -0
- package/README.md +300 -82
- package/README.pt.md +352 -0
- package/bin/dsh-standards.mjs +13 -2
- package/docs/assets/dsh-standards-flow-dark.svg +38 -0
- package/docs/assets/dsh-standards-flow-light.svg +38 -0
- package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
- package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
- package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
- package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
- package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
- package/package.json +2 -2
- package/spec/README.md +2 -0
- package/spec/flow/README.md +19 -6
- package/spec/flow-evidence.schema.json +76 -6
- package/spec/schemas/advice.schema.json +92 -0
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
* 2. copy the package to a temp dir and run every journey against that copy
|
|
10
10
|
* only; the original directory is never written;
|
|
11
11
|
* 3. static preflight (`journey.mjs`) for J1-J8, then the runtime probe
|
|
12
|
-
* (`journey-runtime.mjs`
|
|
13
|
-
* an allowlisted child env;
|
|
14
|
-
*
|
|
12
|
+
* (`journey-runtime.mjs`, which runs `dsh plugin add` and boots the tree)
|
|
13
|
+
* with a temp HOME/DSH_HOME and an allowlisted child env; with
|
|
14
|
+
* `--sandbox` the runtime probe runs behind `unshare` (network),
|
|
15
|
+
* `prlimit` (nproc/as), and a process-group timeout that kills the whole
|
|
16
|
+
* tree. Any missing isolation item makes `producer.kind`
|
|
17
|
+
* `sandbox-degraded`, which is never runtime;
|
|
18
|
+
* 4. re-hash the original tree and record `subject.treeStable`; a changed
|
|
19
|
+
* tree invalidates the evidence;
|
|
15
20
|
* 5. write the JSON, per-check logs, the chain hash, and SHA256SUMS.
|
|
16
21
|
*
|
|
17
22
|
* Missing prerequisites (no dsh, no --previous, host compatibility gate
|
|
@@ -22,7 +27,8 @@
|
|
|
22
27
|
* Usage:
|
|
23
28
|
* node 40-checker/bin/dsh-flow-collect.mjs --package <dir> [--out flow-evidence.json]
|
|
24
29
|
* [--logs <dir>] [--previous <dir>] [--dsh <bin>] [--timeout <ms>]
|
|
25
|
-
* [--static] [--ci] [--
|
|
30
|
+
* [--static] [--ci] [--sandbox [--sandbox-no-network] [--sandbox-no-limits]
|
|
31
|
+
* [--sandbox-nproc <n>] [--sandbox-as <bytes>]] [--json]
|
|
26
32
|
*/
|
|
27
33
|
import fs from 'node:fs'
|
|
28
34
|
import os from 'node:os'
|
|
@@ -30,10 +36,13 @@ import path from 'node:path'
|
|
|
30
36
|
import { spawnSync } from 'node:child_process'
|
|
31
37
|
import { fileURLToPath } from 'node:url'
|
|
32
38
|
import {
|
|
33
|
-
FLOW_IDS, FLOW_EVIDENCE_SCHEMA, clauseAnchor, finalizeEvidence, journeyAggregate,
|
|
39
|
+
FLOW_IDS, FLOW_EVIDENCE_SCHEMA, ciAttribution, clauseAnchor, finalizeEvidence, journeyAggregate,
|
|
34
40
|
sha256File, sha256Value, treeSha256, validateFlowEvidence,
|
|
35
41
|
} from '../src/flow-evidence.mjs'
|
|
36
42
|
import { sanitizedEnv } from '../src/sandbox-env.mjs'
|
|
43
|
+
import {
|
|
44
|
+
assertNoCredentialEnv, buildSandboxPlan, runInProcessTree, sandboxArgv,
|
|
45
|
+
} from '../src/sandbox-run.mjs'
|
|
37
46
|
|
|
38
47
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
39
48
|
const STD = path.resolve(HERE, '..', '..')
|
|
@@ -68,6 +77,15 @@ Options:
|
|
|
68
77
|
--timeout <ms> per-command budget (default: 120000)
|
|
69
78
|
--static skip the runtime probe (J1/J2/J4/J6 become static/blocked)
|
|
70
79
|
--ci mark producer.kind=ci, claims.claimLevel=ci-attested
|
|
80
|
+
(CI attribution is also read from GITHUB_* and stays self-declared)
|
|
81
|
+
--sandbox run the runtime probe under unshare/prlimit/process-group
|
|
82
|
+
timeout with an allowlisted env; producer.kind=sandbox only
|
|
83
|
+
when every isolation item is effective (otherwise
|
|
84
|
+
sandbox-degraded, never runtime)
|
|
85
|
+
--sandbox-no-network force the degraded path (records unshare-missing)
|
|
86
|
+
--sandbox-no-limits force the degraded path (records prlimit-missing)
|
|
87
|
+
--sandbox-nproc <n> RLIMIT_NPROC (default: current task count + headroom)
|
|
88
|
+
--sandbox-as <bytes> RLIMIT_AS (default: 4 GiB)
|
|
71
89
|
--no-assert-unchanged skip the before/after tree hash comparison (not recommended)
|
|
72
90
|
--json print the evidence JSON instead of the summary
|
|
73
91
|
--help show this help
|
|
@@ -82,6 +100,8 @@ function parse(argv) {
|
|
|
82
100
|
package: null, out: path.resolve('flow-evidence.json'), logs: null, previous: null,
|
|
83
101
|
dsh: process.env.DSH_BIN || null, timeout: 120_000, static: false, ci: false,
|
|
84
102
|
assertUnchanged: true, json: false,
|
|
103
|
+
sandbox: false, sandboxNoNetwork: false, sandboxNoLimits: false,
|
|
104
|
+
sandboxNproc: null, sandboxAs: 4 * 1024 ** 3,
|
|
85
105
|
}
|
|
86
106
|
for (let i = 0; i < argv.length; i += 1) {
|
|
87
107
|
const next = () => {
|
|
@@ -99,6 +119,11 @@ function parse(argv) {
|
|
|
99
119
|
else if (arg === '--timeout') out.timeout = Number(next())
|
|
100
120
|
else if (arg === '--static' || arg === '--no-runtime') out.static = true
|
|
101
121
|
else if (arg === '--ci') out.ci = true
|
|
122
|
+
else if (arg === '--sandbox') out.sandbox = true
|
|
123
|
+
else if (arg === '--sandbox-no-network' || arg === '--no-sandbox-network') out.sandboxNoNetwork = true
|
|
124
|
+
else if (arg === '--sandbox-no-limits' || arg === '--no-sandbox-limits') out.sandboxNoLimits = true
|
|
125
|
+
else if (arg === '--sandbox-nproc') out.sandboxNproc = Number(next())
|
|
126
|
+
else if (arg === '--sandbox-as') out.sandboxAs = Number(next())
|
|
102
127
|
else if (arg === '--no-assert-unchanged') out.assertUnchanged = false
|
|
103
128
|
else if (arg === '--json') out.json = true
|
|
104
129
|
else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
|
|
@@ -106,6 +131,10 @@ function parse(argv) {
|
|
|
106
131
|
}
|
|
107
132
|
if (!out.package) throw new Error('--package is required')
|
|
108
133
|
if (!Number.isFinite(out.timeout) || out.timeout < 1000) throw new Error('--timeout must be >= 1000')
|
|
134
|
+
if (out.sandboxNproc !== null && (!Number.isFinite(out.sandboxNproc) || out.sandboxNproc < 2)) throw new Error('--sandbox-nproc must be >= 2')
|
|
135
|
+
if (!Number.isFinite(out.sandboxAs) || out.sandboxAs < 64 * 1024 * 1024) throw new Error('--sandbox-as must be >= 67108864')
|
|
136
|
+
if (out.sandbox && out.static) throw new Error('--sandbox cannot be combined with --static')
|
|
137
|
+
if (out.sandbox && out.ci) throw new Error('--sandbox cannot be combined with --ci (a sandboxed run is sandbox-attested)')
|
|
109
138
|
return out
|
|
110
139
|
}
|
|
111
140
|
|
|
@@ -144,7 +173,7 @@ function writeLog(logDir, name, text) {
|
|
|
144
173
|
return { file, sha256: sha256Value(text ?? '') }
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
function main() {
|
|
176
|
+
async function main() {
|
|
148
177
|
const options = parse(process.argv.slice(2))
|
|
149
178
|
const pkg = path.resolve(options.package)
|
|
150
179
|
if (!fs.existsSync(path.join(pkg, 'package.json'))) throw new Error(`not a package directory: ${pkg}`)
|
|
@@ -162,12 +191,25 @@ function main() {
|
|
|
162
191
|
fs.mkdirSync(tmpHome, { recursive: true })
|
|
163
192
|
fs.mkdirSync(tmpCache, { recursive: true })
|
|
164
193
|
const childEnv = () => sanitizedEnv({
|
|
165
|
-
HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
|
|
194
|
+
HOME: tmpHome, DSH_HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
|
|
166
195
|
npm_config_cache: tmpCache, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false',
|
|
167
196
|
})
|
|
197
|
+
// The sandbox is planned before any plugin code runs. `assertNoCredentialEnv`
|
|
198
|
+
// is a hard gate: a surviving token aborts collection instead of booting with it.
|
|
199
|
+
let sandboxPlan = null
|
|
168
200
|
|
|
169
201
|
const warnings = []
|
|
170
202
|
try {
|
|
203
|
+
if (options.sandbox) {
|
|
204
|
+
assertNoCredentialEnv(childEnv())
|
|
205
|
+
sandboxPlan = buildSandboxPlan({
|
|
206
|
+
env: childEnv(),
|
|
207
|
+
nproc: options.sandboxNproc,
|
|
208
|
+
asBytes: options.sandboxAs,
|
|
209
|
+
force: { network: options.sandboxNoNetwork, limits: options.sandboxNoLimits },
|
|
210
|
+
})
|
|
211
|
+
sandboxPlan.env = assertNoCredentialEnv(childEnv())
|
|
212
|
+
}
|
|
171
213
|
// 0. Never let our own output/log dirs count as a tree change.
|
|
172
214
|
const treeExclude = []
|
|
173
215
|
for (const candidate of [outFile, logDir]) {
|
|
@@ -245,13 +287,59 @@ function main() {
|
|
|
245
287
|
if (options.static) runtimeSkipped = 'runtime collection disabled (--static)'
|
|
246
288
|
else if (dshVersion === null) runtimeSkipped = `dsh executable not usable: ${dshBin} (exit ${versionRun.exitCode})`
|
|
247
289
|
let runtimeVerdict = null
|
|
290
|
+
let sandboxExecution = null
|
|
248
291
|
if (runtimeSkipped === null) {
|
|
249
292
|
const args = [JOURNEY_RUNTIME, '--package', copy, '--dsh', dshBin, '--json', '--log-dir', logDir, '--timeout', String(options.timeout)]
|
|
250
293
|
if (previousCopy) args.push('--previous', previousCopy)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
294
|
+
let runtimeRun
|
|
295
|
+
if (sandboxPlan) {
|
|
296
|
+
// journey-runtime spawns `dsh plugin add` and the boot as its own
|
|
297
|
+
// children; they inherit this process group and network namespace.
|
|
298
|
+
const argv = sandboxArgv(sandboxPlan, process.execPath, args)
|
|
299
|
+
runtimeRun = await runInProcessTree({
|
|
300
|
+
command: argv[0],
|
|
301
|
+
args: argv.slice(1),
|
|
302
|
+
cwd: copy,
|
|
303
|
+
env: childEnv(),
|
|
304
|
+
timeoutMs: options.timeout * 2 + 60_000,
|
|
305
|
+
})
|
|
306
|
+
sandboxExecution = {
|
|
307
|
+
command: argv,
|
|
308
|
+
exitCode: runtimeRun.exitCode,
|
|
309
|
+
timedOut: runtimeRun.timedOut,
|
|
310
|
+
killed: runtimeRun.killed,
|
|
311
|
+
spawnError: runtimeRun.spawnError ?? null,
|
|
312
|
+
durationMs: runtimeRun.durationMs,
|
|
313
|
+
pid: runtimeRun.pid ?? null,
|
|
314
|
+
processTree: {
|
|
315
|
+
mechanism: runtimeRun.treeKill?.mechanism ?? sandboxPlan.processTreeKill.mechanism,
|
|
316
|
+
signalled: runtimeRun.treeKill?.signalled ?? null,
|
|
317
|
+
cleared: runtimeRun.treeKill?.cleared ?? null,
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
if (runtimeRun.spawnError) {
|
|
321
|
+
// The probe passed but the sandboxed spawn itself failed: do not keep
|
|
322
|
+
// calling this run `sandbox`.
|
|
323
|
+
sandboxPlan.degraded = [...new Set([...sandboxPlan.degraded, 'sandbox-execution-failed'])]
|
|
324
|
+
sandboxPlan.mode = 'sandbox-degraded'
|
|
325
|
+
}
|
|
326
|
+
if (runtimeRun.timedOut) {
|
|
327
|
+
runtimeSkipped = `sandboxed runtime probe timed out after ${options.timeout * 2 + 60_000} ms; whole process tree killed (${sandboxExecution.processTree.mechanism}, cleared=${sandboxExecution.processTree.cleared})`
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
runtimeRun = run(process.execPath, args, { env: childEnv(), timeout: options.timeout * 2 + 60_000 })
|
|
331
|
+
}
|
|
332
|
+
const stdoutText = runtimeRun.stdoutText ?? runtimeRun.stdout ?? ''
|
|
333
|
+
const stderrText = runtimeRun.stderrText ?? runtimeRun.stderr ?? ''
|
|
334
|
+
writeLog(logDir, 'runtime-journey.json', stdoutText)
|
|
335
|
+
if (stderrText) writeLog(logDir, 'runtime-journey.err.log', stderrText)
|
|
336
|
+
if (runtimeSkipped === null) {
|
|
337
|
+
try {
|
|
338
|
+
runtimeVerdict = JSON.parse(stdoutText)
|
|
339
|
+
} catch {
|
|
340
|
+
runtimeSkipped = `runtime probe produced no JSON (exit ${runtimeRun.exitCode}): ${tailLines(stderrText, 5)}`
|
|
341
|
+
}
|
|
342
|
+
}
|
|
255
343
|
if (runtimeVerdict) {
|
|
256
344
|
for (const check of runtimeVerdict.checks ?? []) {
|
|
257
345
|
const mapped = toEvidenceCheck(check, logDir, toEvidencePath)
|
|
@@ -267,6 +355,9 @@ function main() {
|
|
|
267
355
|
}
|
|
268
356
|
}
|
|
269
357
|
}
|
|
358
|
+
if (sandboxPlan) {
|
|
359
|
+
writeLog(logDir, 'sandbox-plan.json', JSON.stringify({ ...sandboxPlan, execution: sandboxExecution }, null, 2))
|
|
360
|
+
}
|
|
270
361
|
if (runtimeSkipped !== null) warnings.push(`runtime probe skipped: ${runtimeSkipped}`)
|
|
271
362
|
|
|
272
363
|
// 6. Journey statuses + reasons (never upgrade a skip to a pass).
|
|
@@ -342,18 +433,45 @@ function main() {
|
|
|
342
433
|
? 'flow-fail'
|
|
343
434
|
: statuses.includes('blocked') ? 'flow-blocked' : statuses.every(status => status === 'pass') ? 'flow-pass' : 'flow-warn'
|
|
344
435
|
const producerCommand = [process.execPath, path.relative(STD, fileURLToPath(import.meta.url)).split(path.sep).join('/'), ...process.argv.slice(2)]
|
|
436
|
+
const sandboxExecuted = Boolean(sandboxExecution)
|
|
437
|
+
const sandboxKind = sandboxPlan && sandboxExecuted
|
|
438
|
+
? (sandboxPlan.degraded.length === 0 ? 'sandbox' : 'sandbox-degraded')
|
|
439
|
+
: null
|
|
440
|
+
const producerKind = sandboxKind ?? (options.ci ? 'ci' : 'local')
|
|
441
|
+
const notClaimed = [...NOT_CLAIMED]
|
|
442
|
+
if (sandboxKind === 'sandbox') {
|
|
443
|
+
const localIndex = notClaimed.findIndex(item => item.startsWith('sandbox confinement'))
|
|
444
|
+
if (localIndex >= 0) notClaimed.splice(localIndex, 1)
|
|
445
|
+
notClaimed.push('OCI/container image, seccomp, and cgroup memory/pids limits (not implemented)', 'signature (not produced)')
|
|
446
|
+
} else if (sandboxKind === 'sandbox-degraded') {
|
|
447
|
+
notClaimed.push(`sandbox confinement (degraded: ${sandboxPlan.degraded.join(', ')})`)
|
|
448
|
+
}
|
|
449
|
+
const claimLevel = sandboxKind === 'sandbox' ? 'sandbox-attested' : options.ci ? 'ci-attested' : 'self-attested'
|
|
450
|
+
const method = sandboxKind === 'sandbox'
|
|
451
|
+
? 'sandboxed collector: allowlisted env (no tokens), temporary HOME/DSH_HOME, network+user namespace, prlimit nproc/as, process-group SIGKILL timeout'
|
|
452
|
+
: sandboxKind === 'sandbox-degraded'
|
|
453
|
+
? `sandboxed collector DEGRADED (${sandboxPlan.degraded.join(', ')}): allowlisted env, temporary HOME/DSH_HOME, process-group SIGKILL timeout; every missing isolation item is recorded`
|
|
454
|
+
: 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption'
|
|
455
|
+
const sandbox = sandboxPlan ? { ...sandboxPlan, executed: sandboxExecuted, execution: sandboxExecution } : null
|
|
456
|
+
// CI attribution is display-only and self-declared. It is recorded from the
|
|
457
|
+
// runner environment even without --ci; it never upgrades the claim level.
|
|
458
|
+
const ci = ciAttribution()
|
|
459
|
+
const ciFields = ci ? Object.fromEntries(Object.entries(ci).filter(([, value]) => value !== null)) : {}
|
|
345
460
|
const document = finalizeEvidence({
|
|
346
461
|
schema: FLOW_EVIDENCE_SCHEMA,
|
|
347
462
|
generatedAt,
|
|
348
|
-
producer: {
|
|
463
|
+
producer: {
|
|
464
|
+
command: producerCommand,
|
|
465
|
+
at: generatedAt,
|
|
466
|
+
kind: producerKind,
|
|
467
|
+
runner: sandboxPlan ? 'flow-collect/2-sandbox' : 'flow-collect/1',
|
|
468
|
+
...ciFields,
|
|
469
|
+
},
|
|
470
|
+
...(sandbox ? { sandbox } : {}),
|
|
349
471
|
subject,
|
|
350
472
|
host,
|
|
351
473
|
journeys,
|
|
352
|
-
claims: {
|
|
353
|
-
claimLevel: options.ci ? 'ci-attested' : 'self-attested',
|
|
354
|
-
notClaimed: NOT_CLAIMED,
|
|
355
|
-
method: 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption',
|
|
356
|
-
},
|
|
474
|
+
claims: { claimLevel, notClaimed, method },
|
|
357
475
|
conclusion,
|
|
358
476
|
})
|
|
359
477
|
const verdict = validateFlowEvidence(document)
|
|
@@ -385,8 +503,17 @@ function main() {
|
|
|
385
503
|
` totals : ${JSON.stringify(counts)}`,
|
|
386
504
|
` conclusion: ${conclusion}`,
|
|
387
505
|
` chain : ${document.chain.evidenceHash}`,
|
|
506
|
+
...(sandboxPlan ? [
|
|
507
|
+
` sandbox : ${sandboxPlan.mode}${sandboxPlan.degraded.length > 0 ? ` degraded=[${sandboxPlan.degraded.join(', ')}]` : ''}`,
|
|
508
|
+
` env=${sandboxPlan.env.policy} tokens-absent=${sandboxPlan.env.absent} network=${sandboxPlan.network.isolated ? sandboxPlan.network.mechanism : 'off'} limits=${sandboxPlan.limits.available ? sandboxPlan.limits.applied.join('+') : 'off'} home=${sandboxPlan.home.isolated ? 'temp' : 'real'}`,
|
|
509
|
+
` execution=${sandboxExecuted ? `exit=${sandboxExecution.exitCode} timedOut=${sandboxExecution.timedOut} killed=${sandboxExecution.killed} treeCleared=${sandboxExecution.processTree.cleared}` : 'not-run'}`,
|
|
510
|
+
] : []),
|
|
388
511
|
` logs : ${logDir} (+ ${path.join(outDir, 'SHA256SUMS')})`,
|
|
389
|
-
|
|
512
|
+
sandboxKind === 'sandbox'
|
|
513
|
+
? 'Claims: sandbox-attested (containment only; no OCI image, seccomp, cgroup, signature); not a certification or security audit.'
|
|
514
|
+
: sandboxKind === 'sandbox-degraded'
|
|
515
|
+
? `Claims: self-attested; sandbox DEGRADED (${sandboxPlan.degraded.join(', ')}). Degraded evidence is never FLOW runtime.`
|
|
516
|
+
: 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
|
|
390
517
|
]
|
|
391
518
|
if (warnings.length > 0) {
|
|
392
519
|
lines.push('Warnings:')
|
|
@@ -486,9 +613,7 @@ function collectHints(journeyId, checks, hints, outDir, logDir, toEvidencePath)
|
|
|
486
613
|
}
|
|
487
614
|
}
|
|
488
615
|
|
|
489
|
-
|
|
490
|
-
main()
|
|
491
|
-
} catch (error) {
|
|
616
|
+
main().catch(error => {
|
|
492
617
|
console.error(`flow-collect: ${error.message}`)
|
|
493
618
|
process.exitCode = 3
|
|
494
|
-
}
|
|
619
|
+
})
|
|
@@ -31,7 +31,7 @@ import fs from 'node:fs'
|
|
|
31
31
|
import path from 'node:path'
|
|
32
32
|
import { spawnSync } from 'node:child_process'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
|
-
import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, validateFlowEvidence } from '../src/flow-evidence.mjs'
|
|
34
|
+
import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, bindEvidenceSubject, validateFlowEvidence } from '../src/flow-evidence.mjs'
|
|
35
35
|
|
|
36
36
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
37
37
|
const STD = path.resolve(HERE, '..', '..')
|
|
@@ -52,6 +52,10 @@ Options:
|
|
|
52
52
|
--flow-evidence <file> dsh-flow-evidence/2 JSON from dsh-flow-collect.mjs (J1-J8 checks
|
|
53
53
|
with command/exitCode/times/stdout sha256/assertions). v1 and bare
|
|
54
54
|
{Jx:{status}} files are reported deprecated and never count as pass.
|
|
55
|
+
--dir <dir> bind the flow evidence to this package directory: name/version,
|
|
56
|
+
recomputed treeSha256 and npm-pack tarball hash must match the
|
|
57
|
+
evidence subject, otherwise FLOW becomes invalid. If the positional
|
|
58
|
+
directory is omitted, --dir is used for the ENTRY check too.
|
|
55
59
|
--boost-evidence <file> JSON: { "LEV-3": { "status": "measured|not-measured|failed", "evidenceCard": { ... } }, ... }
|
|
56
60
|
measured requires a valid evidence card; a status-only row is downgraded to not-measured
|
|
57
61
|
--timeout <ms> checker timeout, default 150000
|
|
@@ -65,7 +69,7 @@ are reported as not-run / not-measured.`)
|
|
|
65
69
|
|
|
66
70
|
function parseArgs(argv) {
|
|
67
71
|
const options = {
|
|
68
|
-
dir: null, host: null, base: null, bare: null, runtime: true,
|
|
72
|
+
dir: null, bindDir: null, host: null, base: null, bare: null, runtime: true,
|
|
69
73
|
flowEvidence: null, boostEvidence: null, timeout: 150_000,
|
|
70
74
|
format: 'text', out: null,
|
|
71
75
|
}
|
|
@@ -82,6 +86,7 @@ function parseArgs(argv) {
|
|
|
82
86
|
else if (arg === '--bare') options.bare = next()
|
|
83
87
|
else if (arg === '--no-runtime') options.runtime = false
|
|
84
88
|
else if (arg === '--flow-evidence') options.flowEvidence = next()
|
|
89
|
+
else if (arg === '--dir') options.bindDir = next()
|
|
85
90
|
else if (arg === '--boost-evidence') options.boostEvidence = next()
|
|
86
91
|
else if (arg === '--timeout') options.timeout = Number(next())
|
|
87
92
|
else if (arg === '--format') options.format = next()
|
|
@@ -91,11 +96,17 @@ function parseArgs(argv) {
|
|
|
91
96
|
else if (options.dir === null) options.dir = arg
|
|
92
97
|
else throw new Error(`unexpected argument: ${arg}`)
|
|
93
98
|
}
|
|
94
|
-
if (
|
|
99
|
+
if (options.bindDir !== null && options.dir !== null && path.resolve(options.bindDir) !== path.resolve(options.dir)) {
|
|
100
|
+
throw new Error('the positional directory and --dir point at different packages')
|
|
101
|
+
}
|
|
102
|
+
if (!options.dir) options.dir = options.bindDir
|
|
103
|
+
if (!options.dir) throw new Error('a plugin directory is required (positional or --dir)')
|
|
95
104
|
if (!['text', 'json'].includes(options.format)) throw new Error('--format must be text or json')
|
|
96
105
|
if (!Number.isFinite(options.timeout) || options.timeout < 1000) throw new Error('--timeout must be >= 1000')
|
|
97
106
|
options.dir = path.resolve(options.dir)
|
|
107
|
+
if (options.bindDir !== null) options.bindDir = path.resolve(options.bindDir)
|
|
98
108
|
if (!fs.existsSync(path.join(options.dir, 'package.json'))) throw new Error(`not a package directory: ${options.dir}`)
|
|
109
|
+
if (options.bindDir !== null && !fs.existsSync(path.join(options.bindDir, 'package.json'))) throw new Error(`not a package directory: ${options.bindDir}`)
|
|
99
110
|
return options
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -158,15 +169,26 @@ function classifyEntry(report, invoked) {
|
|
|
158
169
|
}
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
function
|
|
172
|
+
function attestationOf(evidence) {
|
|
173
|
+
const producer = evidence && typeof evidence.producer === 'object' ? evidence.producer : null
|
|
174
|
+
if (producer?.kind === 'ci') return 'ci'
|
|
175
|
+
if (typeof producer?.runUrl === 'string' && producer.runUrl !== '') return 'ci'
|
|
176
|
+
return 'self-declared'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function classifyFlow(evidence, binding = null) {
|
|
162
180
|
const ids = FLOW_IDS
|
|
163
181
|
const emptyJourneys = () => Object.fromEntries(ids.map(id => [id, { status: 'not-run', declared: null, checks: 0, passes: false }]))
|
|
164
182
|
if (!evidence) {
|
|
165
183
|
return {
|
|
166
184
|
state: 'not-run',
|
|
185
|
+
attestation: 'self-declared',
|
|
186
|
+
subject: binding,
|
|
167
187
|
schema: null,
|
|
168
188
|
claimLevel: null,
|
|
169
189
|
producerKind: null,
|
|
190
|
+
sandboxMode: null,
|
|
191
|
+
sandboxDegraded: [],
|
|
170
192
|
sandboxAttested: false,
|
|
171
193
|
deprecated: false,
|
|
172
194
|
invalid: false,
|
|
@@ -183,9 +205,13 @@ function classifyFlow(evidence) {
|
|
|
183
205
|
return row?.status ?? null
|
|
184
206
|
}
|
|
185
207
|
const base = {
|
|
208
|
+
attestation: attestationOf(evidence),
|
|
209
|
+
subject: binding,
|
|
186
210
|
schema: verdict.schema,
|
|
187
211
|
claimLevel: verdict.claimLevel ?? null,
|
|
188
212
|
producerKind: verdict.producerKind ?? null,
|
|
213
|
+
sandboxMode: verdict.sandbox?.mode ?? null,
|
|
214
|
+
sandboxDegraded: verdict.sandbox?.degraded ?? [],
|
|
189
215
|
sandboxAttested: verdict.sandboxAttested === true,
|
|
190
216
|
deprecated: verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy',
|
|
191
217
|
invalid: false,
|
|
@@ -201,12 +227,19 @@ function classifyFlow(evidence) {
|
|
|
201
227
|
journeys: Object.fromEntries(ids.map(id => [id, { status: 'deprecated', declared: declaredOf(id), checks: 0, passes: false }])),
|
|
202
228
|
}
|
|
203
229
|
}
|
|
204
|
-
|
|
230
|
+
const bindingErrors = binding && !binding.ok
|
|
231
|
+
? [...(binding.errors ?? []), ...(binding.mismatches ?? [])].map(message => `subject binding: ${message}`)
|
|
232
|
+
: []
|
|
233
|
+
if (!verdict.valid || bindingErrors.length > 0) {
|
|
234
|
+
const errors = [...verdict.errors, ...bindingErrors]
|
|
205
235
|
return {
|
|
206
236
|
...base,
|
|
207
237
|
state: 'invalid',
|
|
208
238
|
invalid: true,
|
|
209
|
-
|
|
239
|
+
errors,
|
|
240
|
+
reason: bindingErrors.length > 0
|
|
241
|
+
? `evidence subject does not match the inspected package: ${[...(binding.errors ?? []), ...(binding.mismatches ?? [])].join('; ')}`
|
|
242
|
+
: `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
|
|
210
243
|
evidenceHash: {
|
|
211
244
|
declared: verdict.declaredHash,
|
|
212
245
|
computed: verdict.computedHash,
|
|
@@ -245,7 +278,11 @@ function classifyFlow(evidence) {
|
|
|
245
278
|
? 'runtime'
|
|
246
279
|
: 'not-run'
|
|
247
280
|
const reasons = []
|
|
248
|
-
|
|
281
|
+
const degraded = Array.isArray(base.sandboxDegraded) ? base.sandboxDegraded : []
|
|
282
|
+
if (degraded.length > 0) {
|
|
283
|
+
reasons.push(`sandbox-degraded (${degraded.join(', ')}); runtime requires every isolation item effective`)
|
|
284
|
+
}
|
|
285
|
+
if (!runtimeEligible && failed.length === 0 && degraded.length === 0) {
|
|
249
286
|
reasons.push(`${base.claimLevel ?? 'non-sandbox'} evidence; runtime requires sandbox-attested`)
|
|
250
287
|
}
|
|
251
288
|
if (failed.length > 0) reasons.push(`failed journeys: ${failed.join(', ')}`)
|
|
@@ -362,7 +399,9 @@ function nextActions(entry, flow, boost) {
|
|
|
362
399
|
if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
|
|
363
400
|
for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
|
|
364
401
|
if (entry.state === 'recognized') actions.push('ENTRY: run with --host to replace static recognition with a real G0-G3 boot')
|
|
365
|
-
if (flow.
|
|
402
|
+
if (flow.sandboxDegraded?.length > 0) {
|
|
403
|
+
actions.push(`FLOW: sandbox degraded (${flow.sandboxDegraded.join(', ')}); re-run where unshare and prlimit are effective; degraded evidence is never runtime`)
|
|
404
|
+
} else if (flow.state === 'not-run' && flow.claimLevel && flow.sandboxAttested !== true) {
|
|
366
405
|
actions.push(`FLOW: ${flow.claimLevel} evidence keeps ${flow.passed}/8 journey passes but is not runtime; re-run under a sandbox and set claims.claimLevel=sandbox-attested (or producer.kind=sandbox)`)
|
|
367
406
|
} else if (flow.state === 'not-run') {
|
|
368
407
|
actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
|
|
@@ -381,6 +420,7 @@ function render(result) {
|
|
|
381
420
|
`DSH level audit — ${result.target.dir}`,
|
|
382
421
|
` ENTRY : ${result.levels.entry.state.padEnd(12)} (${result.levels.entry.checkerLevel ?? 'no level'})`,
|
|
383
422
|
` FLOW : ${result.levels.flow.state.padEnd(12)} ${result.levels.flow.reason}`,
|
|
423
|
+
` attest: ${result.levels.flow.attestation}${result.levels.flow.subject ? ` (subject ${result.levels.flow.subject.ok ? 'bound' : 'MISMATCH'})` : ''}`,
|
|
384
424
|
` BOOST : ${result.levels.boost.state.padEnd(12)} ${result.levels.boost.reason}`,
|
|
385
425
|
]
|
|
386
426
|
if (result.nextActions.length > 0) {
|
|
@@ -399,7 +439,13 @@ function main() {
|
|
|
399
439
|
|
|
400
440
|
const checker = runChecker(options)
|
|
401
441
|
const entry = classifyEntry(checker.report, checker.invoked)
|
|
402
|
-
|
|
442
|
+
// Subject binding is opt-in via --dir: it re-reads the package and re-packs
|
|
443
|
+
// it, so an evidence file can no longer vouch for a different version/hash.
|
|
444
|
+
let binding = null
|
|
445
|
+
if (flowEvidence && options.bindDir) {
|
|
446
|
+
binding = bindEvidenceSubject(flowEvidence, options.bindDir, { timeoutMs: options.timeout })
|
|
447
|
+
}
|
|
448
|
+
const flow = classifyFlow(flowEvidence, binding)
|
|
403
449
|
const boost = classifyBoost(boostEvidence)
|
|
404
450
|
|
|
405
451
|
const result = {
|
|
@@ -408,6 +454,8 @@ function main() {
|
|
|
408
454
|
target: { dir: options.dir },
|
|
409
455
|
levelsSpec: { schema: levels.schema, version: levels.version, status: levels.status },
|
|
410
456
|
levels: { entry, flow, boost },
|
|
457
|
+
attestation: flow.attestation,
|
|
458
|
+
subject: binding,
|
|
411
459
|
nextActions: nextActions(entry, flow, boost),
|
|
412
460
|
notClaimed: ['official standard', 'certification', 'security audit', 'ranking', 'production baseline'],
|
|
413
461
|
checker: {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Runs the checker tests when the dev-only test tree is present. The npm package
|
|
2
|
+
// does not ship 40-checker/test, so an installed copy must not fail `npm test`
|
|
3
|
+
// on a missing directory.
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const dirs = [join(here, '..', 'test'), join(here, '..', '..', 'test')]
|
|
11
|
+
const files = dirs.filter((dir) => existsSync(dir)).flatMap((dir) => readdirSync(dir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(dir, f)))
|
|
12
|
+
if (files.length === 0) {
|
|
13
|
+
console.log('checker tests are not shipped in the npm package; nothing to run')
|
|
14
|
+
process.exit(0)
|
|
15
|
+
}
|
|
16
|
+
const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
|
|
17
|
+
process.exit(r.status ?? 1)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-standards check [dir]` — the author-facing entry point.
|
|
3
|
+
*
|
|
4
|
+
* It shells out to the existing checker in JSON mode and only changes how the
|
|
5
|
+
* same report is presented: Markdown by default, `--json` for the raw
|
|
6
|
+
* `dsh-conformance/1` document, `--html <file>` for a single self-contained
|
|
7
|
+
* file. Exit codes are the checker's, never rewritten here.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { renderHtml, renderMarkdown } from './author-report.mjs'
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const CHECKER = path.resolve(HERE, '..', 'bin', 'dsh-plugin-verify.mjs')
|
|
17
|
+
const MAX_BUFFER = 64 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
/** Checker flags that take a value, so `check --scope entry` is not read as a directory. */
|
|
20
|
+
const VALUE_FLAGS = new Set([
|
|
21
|
+
'--scope', '--profile', '--runtime', '--runtime-version', '--cordis-version', '--commit',
|
|
22
|
+
'--install-spec', '--market-index', '--plugin-name', '--timeout', '--host', '--base',
|
|
23
|
+
'--web', '--bare', '--entry-runtime', '--entry-claim',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
export function checkUsage() {
|
|
27
|
+
return `dsh-standards check — author-facing standards report
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
dsh-standards check [dir] [options]
|
|
31
|
+
|
|
32
|
+
dir defaults to the current directory. Output defaults to Markdown.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--json print the raw dsh-conformance/1 report
|
|
36
|
+
--html <file> write a single-file HTML report (inline CSS, no assets)
|
|
37
|
+
--help show this help
|
|
38
|
+
|
|
39
|
+
Any other flag is forwarded to the checker, e.g.:
|
|
40
|
+
--scope entry --entry-claim static --no-exec
|
|
41
|
+
--runtime-version 0.2.2-rc.1
|
|
42
|
+
--market-index index.json --plugin-name @scope/name
|
|
43
|
+
|
|
44
|
+
Exit codes (same as the checker):
|
|
45
|
+
0 completed, no FAIL (PASS or WARN)
|
|
46
|
+
1 completed, at least one FAIL
|
|
47
|
+
2 incomplete: a mandatory check did not run
|
|
48
|
+
3 usage error
|
|
49
|
+
`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseCheckArgs(argv = []) {
|
|
53
|
+
const options = { dir: null, json: false, html: null, help: false, passthrough: [] }
|
|
54
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
55
|
+
const arg = argv[index]
|
|
56
|
+
if (arg === '--help' || arg === '-h') { options.help = true; continue }
|
|
57
|
+
if (arg === '--json') { options.json = true; continue }
|
|
58
|
+
if (arg === '--html' || arg.startsWith('--html=')) {
|
|
59
|
+
const inline = arg.startsWith('--html=') ? arg.slice('--html='.length) : undefined
|
|
60
|
+
if (inline !== undefined) {
|
|
61
|
+
if (inline.trim() === '') throw new Error('--html needs a file path')
|
|
62
|
+
options.html = inline
|
|
63
|
+
} else {
|
|
64
|
+
const next = argv[index + 1]
|
|
65
|
+
if (next === undefined || next.startsWith('--')) throw new Error('--html needs a file path')
|
|
66
|
+
options.html = next
|
|
67
|
+
index += 1
|
|
68
|
+
}
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (arg === '--format' || arg.startsWith('--format=')) throw new Error('`check` owns the output format; use --json or --html <file>')
|
|
72
|
+
if (arg === '--out' || arg.startsWith('--out=')) throw new Error('`check` writes to stdout or --html <file>; --out is not forwarded')
|
|
73
|
+
if (arg.startsWith('-')) {
|
|
74
|
+
options.passthrough.push(arg)
|
|
75
|
+
if (arg.includes('=')) continue
|
|
76
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
77
|
+
const next = argv[index + 1]
|
|
78
|
+
if (next === undefined) throw new Error(`${arg} needs a value`)
|
|
79
|
+
options.passthrough.push(next)
|
|
80
|
+
index += 1
|
|
81
|
+
}
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (options.dir !== null) throw new Error(`unexpected positional argument: ${arg}`)
|
|
85
|
+
options.dir = arg
|
|
86
|
+
}
|
|
87
|
+
if (options.json && options.html) throw new Error('--json and --html are mutually exclusive')
|
|
88
|
+
if (options.dir === null) options.dir = '.'
|
|
89
|
+
options.dir = path.resolve(options.dir)
|
|
90
|
+
return options
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function invokeChecker(options) {
|
|
94
|
+
const args = [CHECKER, options.dir, '--format', 'json', ...options.passthrough]
|
|
95
|
+
const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 600_000, maxBuffer: MAX_BUFFER })
|
|
96
|
+
if (run.error) throw new Error(`could not run the checker: ${run.error.message}`)
|
|
97
|
+
let report
|
|
98
|
+
try {
|
|
99
|
+
report = JSON.parse(run.stdout)
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(`checker produced no JSON (exit ${run.status}): ${String(run.stderr ?? '').slice(0, 400)}`)
|
|
102
|
+
}
|
|
103
|
+
return { report, status: run.status }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function runCheck(argv = process.argv.slice(2)) {
|
|
107
|
+
let options
|
|
108
|
+
try {
|
|
109
|
+
options = parseCheckArgs(argv)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
process.stderr.write(`dsh-standards check: ${error.message}\n`)
|
|
112
|
+
process.stderr.write(checkUsage())
|
|
113
|
+
return 3
|
|
114
|
+
}
|
|
115
|
+
if (options.help) {
|
|
116
|
+
process.stdout.write(checkUsage())
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
let result
|
|
120
|
+
try {
|
|
121
|
+
result = invokeChecker(options)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
process.stderr.write(`dsh-standards check: ${error.message}\n`)
|
|
124
|
+
return 3
|
|
125
|
+
}
|
|
126
|
+
const { report, status } = result
|
|
127
|
+
const exitCode = Number.isInteger(report?.conclusion?.exitCode) ? report.conclusion.exitCode : (status ?? 3)
|
|
128
|
+
if (options.html) {
|
|
129
|
+
const out = path.resolve(options.html)
|
|
130
|
+
fs.mkdirSync(path.dirname(out), { recursive: true })
|
|
131
|
+
fs.writeFileSync(out, renderHtml(report))
|
|
132
|
+
process.stdout.write(`wrote ${out}\n`)
|
|
133
|
+
} else if (options.json) {
|
|
134
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
135
|
+
} else {
|
|
136
|
+
process.stdout.write(renderMarkdown(report))
|
|
137
|
+
}
|
|
138
|
+
return exitCode
|
|
139
|
+
}
|