dsh-standards 1.2.1 → 1.2.2
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 +7 -2
- package/40-checker/bin/dsh-levels.mjs +7 -1
- package/40-checker/scripts/flow-collect.mjs +140 -21
- package/40-checker/scripts/level-audit.mjs +12 -2
- package/40-checker/scripts/run-tests.mjs +21 -0
- package/40-checker/src/flow-evidence.mjs +83 -2
- package/40-checker/src/sandbox-run.mjs +403 -0
- package/CHANGELOG.md +5 -0
- package/README.en.md +347 -0
- package/README.es.md +347 -0
- package/README.hi.md +347 -0
- package/README.md +295 -82
- package/README.pt.md +347 -0
- package/docs/assets/dsh-standards-flow-dark.svg +38 -0
- package/docs/assets/dsh-standards-flow-light.svg +38 -0
- package/package.json +2 -2
- package/spec/flow/README.md +19 -6
- package/spec/flow-evidence.schema.json +70 -4
package/40-checker/README.md
CHANGED
|
@@ -131,13 +131,18 @@ node scripts/level-audit.mjs ./my-bundle \
|
|
|
131
131
|
```bash
|
|
132
132
|
node bin/dsh-flow-collect.mjs --package ./my-plugin --out flow-evidence.json
|
|
133
133
|
# 等价:node bin/dsh-levels.mjs flow collect --package ./my-plugin --out flow-evidence.json
|
|
134
|
+
# 隔离运行(unshare 无网 + prlimit + 进程组超时杀树 + 白名单 env):
|
|
135
|
+
node bin/dsh-flow-collect.mjs --package ./my-plugin --sandbox --out flow-evidence.json
|
|
136
|
+
# 等价:node bin/dsh-levels.mjs flow sandbox --package ./my-plugin --out flow-evidence.json
|
|
134
137
|
```
|
|
135
138
|
|
|
136
139
|
- 只接受 `dsh-flow-evidence/2`(`spec/flow-evidence.schema.json`)。`level-audit --flow-evidence` 对 v1(`dsh-flow-journey/1`、`dsh-flow-runtime/1`)和裸 `{Jx:{status}}` 标 `deprecated`,不计 pass。
|
|
137
140
|
- 每个 J 必须至少 1 条 `checks[]` 且全部 `exitCode === 0`、断言成立、至少一条非 `static` 观测检查,才计 pass;`static` preflight 不是 runtime,`warn`/`blocked`/`na` 都不计 8/8。
|
|
138
|
-
- collector 把包复制到临时目录再跑,前后 `treeSha256`
|
|
141
|
+
- collector 把包复制到临时目录再跑,前后 `treeSha256` 必须一致;`treeStable:false` 或 `treeSha256After` 不同直接判证据无效(invalid),不是 warn。子进程 env 走白名单(不含 token/key/secret);不授予 `allowBuilds`,不自动 `allow-version`。
|
|
142
|
+
- `--sandbox` 在临时 HOME/DSH_HOME 下跑 `dsh plugin add` + boot:先试 `unshare -n`,不行再试 `unshare -Urn`;用 `prlimit` 设 `nproc`/`as` 并从 `/proc/self/limits` 回读实际生效项;超时 SIGKILL 整个进程组(Windows `taskkill /T /F`)。硬断言 `GH_TOKEN`/`NPM_TOKEN`/`GITHUB_TOKEN` 不在子进程 env,发现即中止。
|
|
143
|
+
- 只有**所有**隔离项都生效才写 `producer.kind=sandbox` / `claims.claimLevel=sandbox-attested`;任何一项缺失(`unshare-missing`、`prlimit-missing`、`env-token-present`、`home-not-isolated`)写 `sandbox-degraded` + `sandbox.degraded[]`,仍是 self-attested,`level-audit` 永不判 runtime。`--sandbox-no-network` / `--sandbox-no-limits` 用于强制验证降级路径。
|
|
139
144
|
- 前置缺失(无 dsh、无 `--previous`、宿主兼容门禁拒绝)记 `blocked` + reason,出口码 0;真失败出口码 1,并打印五要素提示(子检查 / 命令 / exitCode / stderr 末 20 行 / 日志绝对路径 + 条款锚)。
|
|
140
|
-
-
|
|
145
|
+
- 本地证据默认 `self-attested`:结构自洽 + 绑定此包 + 未过期,**不证明真跑过**。即使 `--sandbox` 通过,它也只是 containment(无 seccomp/cgroup/OCI 镜像/签名),不是认证或安全审计。`level-audit` 只有见到 `claims.claimLevel=sandbox-attested` 或 `producer.kind=sandbox` 才判 `runtime`;self/ci/degraded 证据保留 `passed` 计数但记 `not-run`。
|
|
141
146
|
|
|
142
147
|
## 4. 运行 L1–L3
|
|
143
148
|
|
|
@@ -16,8 +16,14 @@ let target = AUDIT
|
|
|
16
16
|
if (args[0] === 'flow' && args[1] === 'collect') {
|
|
17
17
|
args.splice(0, 2)
|
|
18
18
|
target = FLOW_COLLECT
|
|
19
|
+
} else if (args[0] === 'flow' && args[1] === 'sandbox') {
|
|
20
|
+
// Convenience alias for `flow collect --sandbox`.
|
|
21
|
+
args.splice(0, 2)
|
|
22
|
+
args.unshift('--sandbox')
|
|
23
|
+
target = FLOW_COLLECT
|
|
19
24
|
} else if (args[0] === 'flow') {
|
|
20
|
-
console.error('usage: dsh-levels flow collect --package <dir> [--out <file>] [--static] [--previous <dir>]')
|
|
25
|
+
console.error('usage: dsh-levels flow collect --package <dir> [--out <file>] [--static|--sandbox] [--previous <dir>]')
|
|
26
|
+
console.error(' dsh-levels flow sandbox --package <dir> [--out <file>] (= flow collect --sandbox)')
|
|
21
27
|
process.exit(2)
|
|
22
28
|
} else if (args[0] === 'audit') {
|
|
23
29
|
args.shift()
|
|
@@ -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'
|
|
@@ -34,6 +40,9 @@ import {
|
|
|
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,14 @@ 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
|
+
--sandbox run the runtime probe under unshare/prlimit/process-group
|
|
81
|
+
timeout with an allowlisted env; producer.kind=sandbox only
|
|
82
|
+
when every isolation item is effective (otherwise
|
|
83
|
+
sandbox-degraded, never runtime)
|
|
84
|
+
--sandbox-no-network force the degraded path (records unshare-missing)
|
|
85
|
+
--sandbox-no-limits force the degraded path (records prlimit-missing)
|
|
86
|
+
--sandbox-nproc <n> RLIMIT_NPROC (default: current task count + headroom)
|
|
87
|
+
--sandbox-as <bytes> RLIMIT_AS (default: 4 GiB)
|
|
71
88
|
--no-assert-unchanged skip the before/after tree hash comparison (not recommended)
|
|
72
89
|
--json print the evidence JSON instead of the summary
|
|
73
90
|
--help show this help
|
|
@@ -82,6 +99,8 @@ function parse(argv) {
|
|
|
82
99
|
package: null, out: path.resolve('flow-evidence.json'), logs: null, previous: null,
|
|
83
100
|
dsh: process.env.DSH_BIN || null, timeout: 120_000, static: false, ci: false,
|
|
84
101
|
assertUnchanged: true, json: false,
|
|
102
|
+
sandbox: false, sandboxNoNetwork: false, sandboxNoLimits: false,
|
|
103
|
+
sandboxNproc: null, sandboxAs: 4 * 1024 ** 3,
|
|
85
104
|
}
|
|
86
105
|
for (let i = 0; i < argv.length; i += 1) {
|
|
87
106
|
const next = () => {
|
|
@@ -99,6 +118,11 @@ function parse(argv) {
|
|
|
99
118
|
else if (arg === '--timeout') out.timeout = Number(next())
|
|
100
119
|
else if (arg === '--static' || arg === '--no-runtime') out.static = true
|
|
101
120
|
else if (arg === '--ci') out.ci = true
|
|
121
|
+
else if (arg === '--sandbox') out.sandbox = true
|
|
122
|
+
else if (arg === '--sandbox-no-network' || arg === '--no-sandbox-network') out.sandboxNoNetwork = true
|
|
123
|
+
else if (arg === '--sandbox-no-limits' || arg === '--no-sandbox-limits') out.sandboxNoLimits = true
|
|
124
|
+
else if (arg === '--sandbox-nproc') out.sandboxNproc = Number(next())
|
|
125
|
+
else if (arg === '--sandbox-as') out.sandboxAs = Number(next())
|
|
102
126
|
else if (arg === '--no-assert-unchanged') out.assertUnchanged = false
|
|
103
127
|
else if (arg === '--json') out.json = true
|
|
104
128
|
else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
|
|
@@ -106,6 +130,10 @@ function parse(argv) {
|
|
|
106
130
|
}
|
|
107
131
|
if (!out.package) throw new Error('--package is required')
|
|
108
132
|
if (!Number.isFinite(out.timeout) || out.timeout < 1000) throw new Error('--timeout must be >= 1000')
|
|
133
|
+
if (out.sandboxNproc !== null && (!Number.isFinite(out.sandboxNproc) || out.sandboxNproc < 2)) throw new Error('--sandbox-nproc must be >= 2')
|
|
134
|
+
if (!Number.isFinite(out.sandboxAs) || out.sandboxAs < 64 * 1024 * 1024) throw new Error('--sandbox-as must be >= 67108864')
|
|
135
|
+
if (out.sandbox && out.static) throw new Error('--sandbox cannot be combined with --static')
|
|
136
|
+
if (out.sandbox && out.ci) throw new Error('--sandbox cannot be combined with --ci (a sandboxed run is sandbox-attested)')
|
|
109
137
|
return out
|
|
110
138
|
}
|
|
111
139
|
|
|
@@ -144,7 +172,7 @@ function writeLog(logDir, name, text) {
|
|
|
144
172
|
return { file, sha256: sha256Value(text ?? '') }
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
function main() {
|
|
175
|
+
async function main() {
|
|
148
176
|
const options = parse(process.argv.slice(2))
|
|
149
177
|
const pkg = path.resolve(options.package)
|
|
150
178
|
if (!fs.existsSync(path.join(pkg, 'package.json'))) throw new Error(`not a package directory: ${pkg}`)
|
|
@@ -162,12 +190,25 @@ function main() {
|
|
|
162
190
|
fs.mkdirSync(tmpHome, { recursive: true })
|
|
163
191
|
fs.mkdirSync(tmpCache, { recursive: true })
|
|
164
192
|
const childEnv = () => sanitizedEnv({
|
|
165
|
-
HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
|
|
193
|
+
HOME: tmpHome, DSH_HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
|
|
166
194
|
npm_config_cache: tmpCache, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false',
|
|
167
195
|
})
|
|
196
|
+
// The sandbox is planned before any plugin code runs. `assertNoCredentialEnv`
|
|
197
|
+
// is a hard gate: a surviving token aborts collection instead of booting with it.
|
|
198
|
+
let sandboxPlan = null
|
|
168
199
|
|
|
169
200
|
const warnings = []
|
|
170
201
|
try {
|
|
202
|
+
if (options.sandbox) {
|
|
203
|
+
assertNoCredentialEnv(childEnv())
|
|
204
|
+
sandboxPlan = buildSandboxPlan({
|
|
205
|
+
env: childEnv(),
|
|
206
|
+
nproc: options.sandboxNproc,
|
|
207
|
+
asBytes: options.sandboxAs,
|
|
208
|
+
force: { network: options.sandboxNoNetwork, limits: options.sandboxNoLimits },
|
|
209
|
+
})
|
|
210
|
+
sandboxPlan.env = assertNoCredentialEnv(childEnv())
|
|
211
|
+
}
|
|
171
212
|
// 0. Never let our own output/log dirs count as a tree change.
|
|
172
213
|
const treeExclude = []
|
|
173
214
|
for (const candidate of [outFile, logDir]) {
|
|
@@ -245,13 +286,59 @@ function main() {
|
|
|
245
286
|
if (options.static) runtimeSkipped = 'runtime collection disabled (--static)'
|
|
246
287
|
else if (dshVersion === null) runtimeSkipped = `dsh executable not usable: ${dshBin} (exit ${versionRun.exitCode})`
|
|
247
288
|
let runtimeVerdict = null
|
|
289
|
+
let sandboxExecution = null
|
|
248
290
|
if (runtimeSkipped === null) {
|
|
249
291
|
const args = [JOURNEY_RUNTIME, '--package', copy, '--dsh', dshBin, '--json', '--log-dir', logDir, '--timeout', String(options.timeout)]
|
|
250
292
|
if (previousCopy) args.push('--previous', previousCopy)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
293
|
+
let runtimeRun
|
|
294
|
+
if (sandboxPlan) {
|
|
295
|
+
// journey-runtime spawns `dsh plugin add` and the boot as its own
|
|
296
|
+
// children; they inherit this process group and network namespace.
|
|
297
|
+
const argv = sandboxArgv(sandboxPlan, process.execPath, args)
|
|
298
|
+
runtimeRun = await runInProcessTree({
|
|
299
|
+
command: argv[0],
|
|
300
|
+
args: argv.slice(1),
|
|
301
|
+
cwd: copy,
|
|
302
|
+
env: childEnv(),
|
|
303
|
+
timeoutMs: options.timeout * 2 + 60_000,
|
|
304
|
+
})
|
|
305
|
+
sandboxExecution = {
|
|
306
|
+
command: argv,
|
|
307
|
+
exitCode: runtimeRun.exitCode,
|
|
308
|
+
timedOut: runtimeRun.timedOut,
|
|
309
|
+
killed: runtimeRun.killed,
|
|
310
|
+
spawnError: runtimeRun.spawnError ?? null,
|
|
311
|
+
durationMs: runtimeRun.durationMs,
|
|
312
|
+
pid: runtimeRun.pid ?? null,
|
|
313
|
+
processTree: {
|
|
314
|
+
mechanism: runtimeRun.treeKill?.mechanism ?? sandboxPlan.processTreeKill.mechanism,
|
|
315
|
+
signalled: runtimeRun.treeKill?.signalled ?? null,
|
|
316
|
+
cleared: runtimeRun.treeKill?.cleared ?? null,
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
if (runtimeRun.spawnError) {
|
|
320
|
+
// The probe passed but the sandboxed spawn itself failed: do not keep
|
|
321
|
+
// calling this run `sandbox`.
|
|
322
|
+
sandboxPlan.degraded = [...new Set([...sandboxPlan.degraded, 'sandbox-execution-failed'])]
|
|
323
|
+
sandboxPlan.mode = 'sandbox-degraded'
|
|
324
|
+
}
|
|
325
|
+
if (runtimeRun.timedOut) {
|
|
326
|
+
runtimeSkipped = `sandboxed runtime probe timed out after ${options.timeout * 2 + 60_000} ms; whole process tree killed (${sandboxExecution.processTree.mechanism}, cleared=${sandboxExecution.processTree.cleared})`
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
runtimeRun = run(process.execPath, args, { env: childEnv(), timeout: options.timeout * 2 + 60_000 })
|
|
330
|
+
}
|
|
331
|
+
const stdoutText = runtimeRun.stdoutText ?? runtimeRun.stdout ?? ''
|
|
332
|
+
const stderrText = runtimeRun.stderrText ?? runtimeRun.stderr ?? ''
|
|
333
|
+
writeLog(logDir, 'runtime-journey.json', stdoutText)
|
|
334
|
+
if (stderrText) writeLog(logDir, 'runtime-journey.err.log', stderrText)
|
|
335
|
+
if (runtimeSkipped === null) {
|
|
336
|
+
try {
|
|
337
|
+
runtimeVerdict = JSON.parse(stdoutText)
|
|
338
|
+
} catch {
|
|
339
|
+
runtimeSkipped = `runtime probe produced no JSON (exit ${runtimeRun.exitCode}): ${tailLines(stderrText, 5)}`
|
|
340
|
+
}
|
|
341
|
+
}
|
|
255
342
|
if (runtimeVerdict) {
|
|
256
343
|
for (const check of runtimeVerdict.checks ?? []) {
|
|
257
344
|
const mapped = toEvidenceCheck(check, logDir, toEvidencePath)
|
|
@@ -267,6 +354,9 @@ function main() {
|
|
|
267
354
|
}
|
|
268
355
|
}
|
|
269
356
|
}
|
|
357
|
+
if (sandboxPlan) {
|
|
358
|
+
writeLog(logDir, 'sandbox-plan.json', JSON.stringify({ ...sandboxPlan, execution: sandboxExecution }, null, 2))
|
|
359
|
+
}
|
|
270
360
|
if (runtimeSkipped !== null) warnings.push(`runtime probe skipped: ${runtimeSkipped}`)
|
|
271
361
|
|
|
272
362
|
// 6. Journey statuses + reasons (never upgrade a skip to a pass).
|
|
@@ -342,18 +432,40 @@ function main() {
|
|
|
342
432
|
? 'flow-fail'
|
|
343
433
|
: statuses.includes('blocked') ? 'flow-blocked' : statuses.every(status => status === 'pass') ? 'flow-pass' : 'flow-warn'
|
|
344
434
|
const producerCommand = [process.execPath, path.relative(STD, fileURLToPath(import.meta.url)).split(path.sep).join('/'), ...process.argv.slice(2)]
|
|
435
|
+
const sandboxExecuted = Boolean(sandboxExecution)
|
|
436
|
+
const sandboxKind = sandboxPlan && sandboxExecuted
|
|
437
|
+
? (sandboxPlan.degraded.length === 0 ? 'sandbox' : 'sandbox-degraded')
|
|
438
|
+
: null
|
|
439
|
+
const producerKind = sandboxKind ?? (options.ci ? 'ci' : 'local')
|
|
440
|
+
const notClaimed = [...NOT_CLAIMED]
|
|
441
|
+
if (sandboxKind === 'sandbox') {
|
|
442
|
+
const localIndex = notClaimed.findIndex(item => item.startsWith('sandbox confinement'))
|
|
443
|
+
if (localIndex >= 0) notClaimed.splice(localIndex, 1)
|
|
444
|
+
notClaimed.push('OCI/container image, seccomp, and cgroup memory/pids limits (not implemented)', 'signature (not produced)')
|
|
445
|
+
} else if (sandboxKind === 'sandbox-degraded') {
|
|
446
|
+
notClaimed.push(`sandbox confinement (degraded: ${sandboxPlan.degraded.join(', ')})`)
|
|
447
|
+
}
|
|
448
|
+
const claimLevel = sandboxKind === 'sandbox' ? 'sandbox-attested' : options.ci ? 'ci-attested' : 'self-attested'
|
|
449
|
+
const method = sandboxKind === 'sandbox'
|
|
450
|
+
? 'sandboxed collector: allowlisted env (no tokens), temporary HOME/DSH_HOME, network+user namespace, prlimit nproc/as, process-group SIGKILL timeout'
|
|
451
|
+
: sandboxKind === 'sandbox-degraded'
|
|
452
|
+
? `sandboxed collector DEGRADED (${sandboxPlan.degraded.join(', ')}): allowlisted env, temporary HOME/DSH_HOME, process-group SIGKILL timeout; every missing isolation item is recorded`
|
|
453
|
+
: 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption'
|
|
454
|
+
const sandbox = sandboxPlan ? { ...sandboxPlan, executed: sandboxExecuted, execution: sandboxExecution } : null
|
|
345
455
|
const document = finalizeEvidence({
|
|
346
456
|
schema: FLOW_EVIDENCE_SCHEMA,
|
|
347
457
|
generatedAt,
|
|
348
|
-
producer: {
|
|
458
|
+
producer: {
|
|
459
|
+
command: producerCommand,
|
|
460
|
+
at: generatedAt,
|
|
461
|
+
kind: producerKind,
|
|
462
|
+
runner: sandboxPlan ? 'flow-collect/2-sandbox' : 'flow-collect/1',
|
|
463
|
+
},
|
|
464
|
+
...(sandbox ? { sandbox } : {}),
|
|
349
465
|
subject,
|
|
350
466
|
host,
|
|
351
467
|
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
|
-
},
|
|
468
|
+
claims: { claimLevel, notClaimed, method },
|
|
357
469
|
conclusion,
|
|
358
470
|
})
|
|
359
471
|
const verdict = validateFlowEvidence(document)
|
|
@@ -385,8 +497,17 @@ function main() {
|
|
|
385
497
|
` totals : ${JSON.stringify(counts)}`,
|
|
386
498
|
` conclusion: ${conclusion}`,
|
|
387
499
|
` chain : ${document.chain.evidenceHash}`,
|
|
500
|
+
...(sandboxPlan ? [
|
|
501
|
+
` sandbox : ${sandboxPlan.mode}${sandboxPlan.degraded.length > 0 ? ` degraded=[${sandboxPlan.degraded.join(', ')}]` : ''}`,
|
|
502
|
+
` 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'}`,
|
|
503
|
+
` execution=${sandboxExecuted ? `exit=${sandboxExecution.exitCode} timedOut=${sandboxExecution.timedOut} killed=${sandboxExecution.killed} treeCleared=${sandboxExecution.processTree.cleared}` : 'not-run'}`,
|
|
504
|
+
] : []),
|
|
388
505
|
` logs : ${logDir} (+ ${path.join(outDir, 'SHA256SUMS')})`,
|
|
389
|
-
|
|
506
|
+
sandboxKind === 'sandbox'
|
|
507
|
+
? 'Claims: sandbox-attested (containment only; no OCI image, seccomp, cgroup, signature); not a certification or security audit.'
|
|
508
|
+
: sandboxKind === 'sandbox-degraded'
|
|
509
|
+
? `Claims: self-attested; sandbox DEGRADED (${sandboxPlan.degraded.join(', ')}). Degraded evidence is never FLOW runtime.`
|
|
510
|
+
: 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
|
|
390
511
|
]
|
|
391
512
|
if (warnings.length > 0) {
|
|
392
513
|
lines.push('Warnings:')
|
|
@@ -486,9 +607,7 @@ function collectHints(journeyId, checks, hints, outDir, logDir, toEvidencePath)
|
|
|
486
607
|
}
|
|
487
608
|
}
|
|
488
609
|
|
|
489
|
-
|
|
490
|
-
main()
|
|
491
|
-
} catch (error) {
|
|
610
|
+
main().catch(error => {
|
|
492
611
|
console.error(`flow-collect: ${error.message}`)
|
|
493
612
|
process.exitCode = 3
|
|
494
|
-
}
|
|
613
|
+
})
|
|
@@ -167,6 +167,8 @@ function classifyFlow(evidence) {
|
|
|
167
167
|
schema: null,
|
|
168
168
|
claimLevel: null,
|
|
169
169
|
producerKind: null,
|
|
170
|
+
sandboxMode: null,
|
|
171
|
+
sandboxDegraded: [],
|
|
170
172
|
sandboxAttested: false,
|
|
171
173
|
deprecated: false,
|
|
172
174
|
invalid: false,
|
|
@@ -186,6 +188,8 @@ function classifyFlow(evidence) {
|
|
|
186
188
|
schema: verdict.schema,
|
|
187
189
|
claimLevel: verdict.claimLevel ?? null,
|
|
188
190
|
producerKind: verdict.producerKind ?? null,
|
|
191
|
+
sandboxMode: verdict.sandbox?.mode ?? null,
|
|
192
|
+
sandboxDegraded: verdict.sandbox?.degraded ?? [],
|
|
189
193
|
sandboxAttested: verdict.sandboxAttested === true,
|
|
190
194
|
deprecated: verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy',
|
|
191
195
|
invalid: false,
|
|
@@ -245,7 +249,11 @@ function classifyFlow(evidence) {
|
|
|
245
249
|
? 'runtime'
|
|
246
250
|
: 'not-run'
|
|
247
251
|
const reasons = []
|
|
248
|
-
|
|
252
|
+
const degraded = Array.isArray(base.sandboxDegraded) ? base.sandboxDegraded : []
|
|
253
|
+
if (degraded.length > 0) {
|
|
254
|
+
reasons.push(`sandbox-degraded (${degraded.join(', ')}); runtime requires every isolation item effective`)
|
|
255
|
+
}
|
|
256
|
+
if (!runtimeEligible && failed.length === 0 && degraded.length === 0) {
|
|
249
257
|
reasons.push(`${base.claimLevel ?? 'non-sandbox'} evidence; runtime requires sandbox-attested`)
|
|
250
258
|
}
|
|
251
259
|
if (failed.length > 0) reasons.push(`failed journeys: ${failed.join(', ')}`)
|
|
@@ -362,7 +370,9 @@ function nextActions(entry, flow, boost) {
|
|
|
362
370
|
if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
|
|
363
371
|
for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
|
|
364
372
|
if (entry.state === 'recognized') actions.push('ENTRY: run with --host to replace static recognition with a real G0-G3 boot')
|
|
365
|
-
if (flow.
|
|
373
|
+
if (flow.sandboxDegraded?.length > 0) {
|
|
374
|
+
actions.push(`FLOW: sandbox degraded (${flow.sandboxDegraded.join(', ')}); re-run where unshare and prlimit are effective; degraded evidence is never runtime`)
|
|
375
|
+
} else if (flow.state === 'not-run' && flow.claimLevel && flow.sandboxAttested !== true) {
|
|
366
376
|
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
377
|
} else if (flow.state === 'not-run') {
|
|
368
378
|
actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
|
|
@@ -0,0 +1,21 @@
|
|
|
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 testDir = join(here, '..', 'test')
|
|
11
|
+
if (!existsSync(testDir)) {
|
|
12
|
+
console.log('checker tests are not shipped in the npm package; nothing to run')
|
|
13
|
+
process.exit(0)
|
|
14
|
+
}
|
|
15
|
+
const files = readdirSync(testDir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(testDir, f))
|
|
16
|
+
if (files.length === 0) {
|
|
17
|
+
console.log('no test files found; nothing to run')
|
|
18
|
+
process.exit(0)
|
|
19
|
+
}
|
|
20
|
+
const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
|
|
21
|
+
process.exit(r.status ?? 1)
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* `producer.kind === 'sandbox'`) may be reported as `flow.state = 'runtime'`.
|
|
20
20
|
* `self-attested`/`ci-attested` documents keep their `passed` count but stay
|
|
21
21
|
* `not-run`, because structural validation cannot prove where commands ran.
|
|
22
|
+
* A `sandbox-degraded` producer (any isolation item missing) is never runtime,
|
|
23
|
+
* and a document whose `subject.treeStable` is false is structurally invalid:
|
|
24
|
+
* a collector that changed the inspected tree has no trustworthy evidence.
|
|
22
25
|
*/
|
|
23
26
|
import crypto from 'node:crypto'
|
|
24
27
|
import fs from 'node:fs'
|
|
@@ -178,6 +181,11 @@ export function journeyCountsAsPass(journey) {
|
|
|
178
181
|
*/
|
|
179
182
|
export function isSandboxAttested(document) {
|
|
180
183
|
if (!isPlainObject(document)) return false
|
|
184
|
+
// Degradation is disqualifying even if a claim was left at sandbox-attested:
|
|
185
|
+
// the producer admits an isolation item was missing.
|
|
186
|
+
if (document.producer?.kind === 'sandbox-degraded') return false
|
|
187
|
+
if (document.sandbox?.mode === 'sandbox-degraded') return false
|
|
188
|
+
if (Array.isArray(document.sandbox?.degraded) && document.sandbox.degraded.length > 0) return false
|
|
181
189
|
return document.claims?.claimLevel === 'sandbox-attested' || document.producer?.kind === 'sandbox'
|
|
182
190
|
}
|
|
183
191
|
|
|
@@ -268,8 +276,8 @@ function validateV2(document) {
|
|
|
268
276
|
if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
|
|
269
277
|
errors.push('producer.command must be a non-empty array of strings')
|
|
270
278
|
}
|
|
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')
|
|
279
|
+
if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox', 'sandbox-degraded'].includes(document.producer.kind)) {
|
|
280
|
+
errors.push('producer.kind must be local, ci, sandbox, or sandbox-degraded')
|
|
273
281
|
}
|
|
274
282
|
for (const key of ['runner', 'actor', 'runUrl']) {
|
|
275
283
|
if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
|
|
@@ -292,6 +300,12 @@ function validateV2(document) {
|
|
|
292
300
|
}
|
|
293
301
|
}
|
|
294
302
|
if ('source' in subject) requireString(subject.source, 'subject.source')
|
|
303
|
+
if (subject.treeStable === false) {
|
|
304
|
+
errors.push('subject.treeStable is false: the package tree changed during collection')
|
|
305
|
+
}
|
|
306
|
+
if ('treeSha256After' in subject && subject.treeSha256After && subject.treeSha256After !== subject.treeSha256 && subject.treeStable !== false) {
|
|
307
|
+
errors.push('subject.treeSha256After differs from subject.treeSha256: the package tree changed during collection')
|
|
308
|
+
}
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
if (requireObject(document.host, 'host')) {
|
|
@@ -344,6 +358,8 @@ function validateV2(document) {
|
|
|
344
358
|
if ('method' in claims) requireString(claims.method, 'claims.method')
|
|
345
359
|
}
|
|
346
360
|
|
|
361
|
+
validateSandbox(document, errors)
|
|
362
|
+
|
|
347
363
|
if (requireObject(document.chain, 'chain')) {
|
|
348
364
|
requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
|
|
349
365
|
for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
|
|
@@ -389,9 +405,74 @@ function validateV2(document) {
|
|
|
389
405
|
conclusion: document.conclusion ?? null,
|
|
390
406
|
claimLevel: isPlainObject(document.claims) ? document.claims.claimLevel ?? null : null,
|
|
391
407
|
producerKind: isPlainObject(document.producer) ? document.producer.kind ?? null : null,
|
|
408
|
+
sandbox: isPlainObject(document.sandbox)
|
|
409
|
+
? {
|
|
410
|
+
mode: document.sandbox.mode ?? null,
|
|
411
|
+
degraded: Array.isArray(document.sandbox.degraded) ? [...document.sandbox.degraded] : [],
|
|
412
|
+
}
|
|
413
|
+
: null,
|
|
392
414
|
sandboxAttested: isSandboxAttested(document),
|
|
393
415
|
}
|
|
394
416
|
|
|
417
|
+
function validateSandbox(doc, errors) {
|
|
418
|
+
const kind = isPlainObject(doc.producer) ? doc.producer.kind ?? null : null
|
|
419
|
+
const declaredSandbox = 'sandbox' in doc
|
|
420
|
+
if (!declaredSandbox && kind !== 'sandbox' && kind !== 'sandbox-degraded') return
|
|
421
|
+
if (!declaredSandbox) {
|
|
422
|
+
errors.push('sandbox is required when producer.kind is sandbox or sandbox-degraded')
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
const sandbox = doc.sandbox
|
|
426
|
+
if (!isPlainObject(sandbox)) {
|
|
427
|
+
errors.push('sandbox must be an object')
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
if (!['sandbox', 'sandbox-degraded'].includes(sandbox.mode)) {
|
|
431
|
+
errors.push('sandbox.mode must be sandbox or sandbox-degraded')
|
|
432
|
+
}
|
|
433
|
+
const degraded = Array.isArray(sandbox.degraded) ? sandbox.degraded : null
|
|
434
|
+
if (!degraded || degraded.some(item => typeof item !== 'string' || item.length === 0)) {
|
|
435
|
+
errors.push('sandbox.degraded must be an array of non-empty strings')
|
|
436
|
+
}
|
|
437
|
+
const list = degraded ?? []
|
|
438
|
+
if (sandbox.mode === 'sandbox' && list.length > 0) errors.push('sandbox.mode is sandbox but sandbox.degraded is not empty')
|
|
439
|
+
if (sandbox.mode === 'sandbox-degraded' && list.length === 0) errors.push('sandbox.mode is sandbox-degraded but sandbox.degraded is empty')
|
|
440
|
+
if ('env' in sandbox) {
|
|
441
|
+
if (!isPlainObject(sandbox.env)) errors.push('sandbox.env must be an object')
|
|
442
|
+
else if (typeof sandbox.env.absent !== 'boolean') errors.push('sandbox.env.absent must be a boolean')
|
|
443
|
+
}
|
|
444
|
+
if ('network' in sandbox && (!isPlainObject(sandbox.network) || typeof sandbox.network.isolated !== 'boolean')) {
|
|
445
|
+
errors.push('sandbox.network.isolated must be a boolean')
|
|
446
|
+
}
|
|
447
|
+
if ('limits' in sandbox) {
|
|
448
|
+
if (!isPlainObject(sandbox.limits)) errors.push('sandbox.limits must be an object')
|
|
449
|
+
else {
|
|
450
|
+
if ('available' in sandbox.limits && typeof sandbox.limits.available !== 'boolean') errors.push('sandbox.limits.available must be a boolean')
|
|
451
|
+
if ('applied' in sandbox.limits && (!Array.isArray(sandbox.limits.applied) || sandbox.limits.applied.some(item => typeof item !== 'string'))) {
|
|
452
|
+
errors.push('sandbox.limits.applied must be an array of strings')
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if ('home' in sandbox && (!isPlainObject(sandbox.home) || typeof sandbox.home.isolated !== 'boolean')) {
|
|
457
|
+
errors.push('sandbox.home.isolated must be a boolean')
|
|
458
|
+
}
|
|
459
|
+
if ('executed' in sandbox && typeof sandbox.executed !== 'boolean') errors.push('sandbox.executed must be a boolean')
|
|
460
|
+
if (kind === 'sandbox') {
|
|
461
|
+
if (sandbox.mode !== 'sandbox') errors.push('producer.kind=sandbox requires sandbox.mode=sandbox')
|
|
462
|
+
if (list.length > 0) errors.push('producer.kind=sandbox requires an empty sandbox.degraded list')
|
|
463
|
+
if (sandbox.executed !== true) errors.push('producer.kind=sandbox requires sandbox.executed=true')
|
|
464
|
+
if (sandbox.env?.absent !== true) errors.push('producer.kind=sandbox requires sandbox.env.absent=true')
|
|
465
|
+
if (sandbox.network?.isolated !== true) errors.push('producer.kind=sandbox requires sandbox.network.isolated=true')
|
|
466
|
+
if (sandbox.limits?.available !== true) errors.push('producer.kind=sandbox requires sandbox.limits.available=true')
|
|
467
|
+
if (sandbox.home?.isolated !== true) errors.push('producer.kind=sandbox requires sandbox.home.isolated=true')
|
|
468
|
+
}
|
|
469
|
+
if (kind === 'sandbox-degraded') {
|
|
470
|
+
if (sandbox.mode !== 'sandbox-degraded') errors.push('producer.kind=sandbox-degraded requires sandbox.mode=sandbox-degraded')
|
|
471
|
+
if (list.length === 0) errors.push('producer.kind=sandbox-degraded requires at least one sandbox.degraded reason')
|
|
472
|
+
if (doc.claims?.claimLevel === 'sandbox-attested') errors.push('producer.kind=sandbox-degraded cannot claim sandbox-attested')
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
395
476
|
function validateCheck(check, label) {
|
|
396
477
|
if (!isPlainObject(check)) {
|
|
397
478
|
errors.push(`${label} must be an object`)
|