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
|
@@ -31,6 +31,46 @@ function adjustEntryCheck(item, entryClaim) {
|
|
|
31
31
|
return { ...item, result: 'WARN', reason: 'C-2.1 version declaration missing/invalid; entry treats it as WARN (C-2.2/C-2.3 remain blocking)' }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Declaration-class checks: a WARN here is hygiene, not a loadability defect. */
|
|
35
|
+
export const DECLARATION_CHECKS = new Set(['DSC-11', 'DSC-12', 'DSC-13'])
|
|
36
|
+
const DETAIL_RANK = { FAIL: 5, ERROR: 4, WARN: 3, PASS: 2, NA: 1 }
|
|
37
|
+
|
|
38
|
+
function bucketRow(item, extra = {}) {
|
|
39
|
+
return {
|
|
40
|
+
id: item.id,
|
|
41
|
+
clause: item.clause ?? null,
|
|
42
|
+
result: item.result,
|
|
43
|
+
coverage: item.coverage ?? null,
|
|
44
|
+
reason: item.reason ?? '',
|
|
45
|
+
...extra,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `entryBlocking` is only sound when every prerequisite was actually evaluated.
|
|
51
|
+
* C-1.x always produce details; C-3.1/3.2/3.5 only produce them when an install
|
|
52
|
+
* spec or market index was supplied. Missing details must therefore make
|
|
53
|
+
* `pass` false instead of silently claiming the gate held.
|
|
54
|
+
*/
|
|
55
|
+
export function summarizeBlocking(checks, blocking) {
|
|
56
|
+
const seen = new Map()
|
|
57
|
+
for (const item of checks) {
|
|
58
|
+
for (const detail of item.details ?? []) {
|
|
59
|
+
if (!blocking.includes(detail.item)) continue
|
|
60
|
+
const rank = DETAIL_RANK[detail.result] ?? 0
|
|
61
|
+
if (!seen.has(detail.item) || rank > (DETAIL_RANK[seen.get(detail.item)] ?? 0)) seen.set(detail.item, detail.result)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const checked = blocking.filter(id => seen.has(id))
|
|
65
|
+
// C-2.3 (workspace: range) has no detail when C-2.2 was evaluated against a
|
|
66
|
+
// normal range; evaluating C-2.2 rules the workspace form out, so count it.
|
|
67
|
+
if (seen.has('C-2.2') && blocking.includes('C-2.3') && !checked.includes('C-2.3')) checked.push('C-2.3')
|
|
68
|
+
checked.sort((a, b) => blocking.indexOf(a) - blocking.indexOf(b))
|
|
69
|
+
const failed = blocking.filter(id => seen.get(id) === 'FAIL')
|
|
70
|
+
const notChecked = blocking.filter(id => !checked.includes(id))
|
|
71
|
+
return { required: blocking.length, checked, notChecked, failed, pass: failed.length === 0 && notChecked.length === 0 }
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
function allowedNa(check, scope, profile, kind) {
|
|
35
75
|
if (check.result !== 'NA') return false
|
|
36
76
|
if (check.id === 'DSC-03' && scope !== 'mvp' && profile === 'package') return true
|
|
@@ -39,7 +79,7 @@ function allowedNa(check, scope, profile, kind) {
|
|
|
39
79
|
return false
|
|
40
80
|
}
|
|
41
81
|
|
|
42
|
-
export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true }) {
|
|
82
|
+
export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null }) {
|
|
43
83
|
const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
|
|
44
84
|
const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
|
|
45
85
|
const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
|
|
@@ -59,6 +99,17 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
59
99
|
const warnings = selected.filter(item => item.result === 'WARN')
|
|
60
100
|
const pass = selected.filter(item => item.result === 'PASS')
|
|
61
101
|
|
|
102
|
+
// Coverage counts every check the report carries (14 in a full run), not just
|
|
103
|
+
// the mandatory subset. A report that only counts its mandatory five hides
|
|
104
|
+
// the runtime checks it never ran; that is the bug this block fixes.
|
|
105
|
+
const evaluatedAll = effectiveChecks.filter(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
106
|
+
const naAll = effectiveChecks.filter(item => item.result === 'NA')
|
|
107
|
+
const errorAll = effectiveChecks.filter(item => item.result === 'ERROR')
|
|
108
|
+
const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && (item.coverage === 'not-run' || item.coverage === 'partial'))
|
|
109
|
+
const notRunAllIds = [...new Set([...notRunAll.map(item => item.id), ...errorAll.map(item => item.id), ...missing])]
|
|
110
|
+
const entryBlockingIds = scope === 'entry' ? (entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING) : null
|
|
111
|
+
const entryBlocking = entryBlockingIds ? summarizeBlocking(effectiveChecks, entryBlockingIds) : null
|
|
112
|
+
|
|
62
113
|
let status
|
|
63
114
|
let exitCode
|
|
64
115
|
if (failed.length > 0) { status = 'fail'; exitCode = 1 }
|
|
@@ -87,6 +138,31 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
87
138
|
if (warnings.length > 0) reasonParts.push(`WARN: ${warnings.map(item => item.id).join(', ')}`)
|
|
88
139
|
if (advisoryWarnings.length > 0) reasonParts.push(`advisory: ${advisoryWarnings.map(item => item.id).join(', ')}`)
|
|
89
140
|
|
|
141
|
+
// Findings by reader-relevant bucket. A check appears in exactly one bucket:
|
|
142
|
+
// FAIL -> blocking, WARN -> hygiene, NA/ERROR/not-run -> notChecked.
|
|
143
|
+
const blockingBucket = effectiveChecks.filter(item => item.result === 'FAIL').map(item => bucketRow(item))
|
|
144
|
+
const hygieneBucket = effectiveChecks.filter(item => item.result === 'WARN').map(item => bucketRow(item, {
|
|
145
|
+
declaration: DECLARATION_CHECKS.has(item.id),
|
|
146
|
+
advisory: item.advisory === true,
|
|
147
|
+
}))
|
|
148
|
+
const notCheckedBucket = effectiveChecks
|
|
149
|
+
.filter(item => item.result === 'NA' || item.result === 'ERROR')
|
|
150
|
+
.map(item => bucketRow(item))
|
|
151
|
+
for (const id of missing) {
|
|
152
|
+
notCheckedBucket.push({ id, clause: null, result: 'NOT-RUN', coverage: 'not-run', reason: 'mandatory check missing' })
|
|
153
|
+
}
|
|
154
|
+
const buckets = { blocking: blockingBucket, hygiene: hygieneBucket, notChecked: notCheckedBucket }
|
|
155
|
+
const declaredFields = Array.isArray(declarations?.present) ? declarations.present.length : 0
|
|
156
|
+
const canMarkSuspect = anchor?.suspect === true
|
|
157
|
+
|
|
158
|
+
const metrics = {
|
|
159
|
+
checked: evaluatedAll.length,
|
|
160
|
+
total: effectiveChecks.length,
|
|
161
|
+
declaredFields,
|
|
162
|
+
}
|
|
163
|
+
const prereq = entryBlocking ? ` (prereq ${entryBlocking.checked.length}/${entryBlocking.required})` : ''
|
|
164
|
+
const headline = `${scope}/${profile} · runtime ${anchor?.dsh ?? 'unknown'}${anchor?.runtimeSource ? ` ${anchor.runtimeSource}` : ''}${canMarkSuspect ? ' SUSPECT' : ''} · blocking ${buckets.blocking.length}${prereq} · hygiene ${buckets.hygiene.length} · not checked ${buckets.notChecked.length}/${effectiveChecks.length}`
|
|
165
|
+
|
|
90
166
|
const report = {
|
|
91
167
|
schema: 'dsh-conformance/1',
|
|
92
168
|
standard: { id: 'DS-C', version: '1.0.0' },
|
|
@@ -95,21 +171,22 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
95
171
|
g4,
|
|
96
172
|
checks: effectiveChecks,
|
|
97
173
|
coverage: {
|
|
174
|
+
total: effectiveChecks.length,
|
|
98
175
|
mandatory: mandatory.length,
|
|
99
|
-
|
|
100
|
-
|
|
176
|
+
checked: evaluatedAll.length,
|
|
177
|
+
pass: evaluatedAll.filter(item => item.result === 'PASS').length,
|
|
178
|
+
warn: evaluatedAll.filter(item => item.result === 'WARN').length,
|
|
101
179
|
advisory: advisoryWarnings.length,
|
|
102
|
-
fail:
|
|
103
|
-
na:
|
|
104
|
-
notRun:
|
|
105
|
-
error:
|
|
106
|
-
entryBlocking
|
|
107
|
-
const blocking = entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING
|
|
108
|
-
const failedItems = effectiveChecks.flatMap(item => (item.details ?? []).filter(detail => detail.result === 'FAIL' && blocking.includes(detail.item)).map(detail => detail.item))
|
|
109
|
-
return { required: blocking.length, failed: failedItems, pass: failedItems.length === 0 }
|
|
110
|
-
})() : null,
|
|
180
|
+
fail: evaluatedAll.filter(item => item.result === 'FAIL').length,
|
|
181
|
+
na: naAll.length,
|
|
182
|
+
notRun: notRunAllIds.length,
|
|
183
|
+
error: errorAll.length,
|
|
184
|
+
entryBlocking,
|
|
111
185
|
},
|
|
186
|
+
buckets,
|
|
187
|
+
metrics,
|
|
112
188
|
conclusion: {
|
|
189
|
+
headline,
|
|
113
190
|
level,
|
|
114
191
|
status,
|
|
115
192
|
exitCode,
|
|
@@ -132,25 +209,49 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
132
209
|
|
|
133
210
|
const ICON = { PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL', NA: 'NA ', ERROR: 'ERR ' }
|
|
134
211
|
|
|
212
|
+
function renderBucket(lines, title, rows, byId) {
|
|
213
|
+
lines.push(`${title} (${rows.length}):`)
|
|
214
|
+
if (rows.length === 0) {
|
|
215
|
+
lines.push(' none')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
|
|
220
|
+
const reason = String(row.reason ?? '').replace(/\s+/g, ' ').slice(0, 150)
|
|
221
|
+
lines.push(` ${(ICON[row.result] ?? row.result).padEnd(4)} ${String(row.id).padEnd(7)} ${String(row.clause ?? '-').padEnd(6)} ${String(row.coverage ?? '-').padEnd(8)} ${reason}${tags.length > 0 ? ` [${tags.join(',')}]` : ''}`)
|
|
222
|
+
const item = byId.get(row.id)
|
|
223
|
+
if (!item || (row.result !== 'FAIL' && row.result !== 'WARN')) continue
|
|
224
|
+
for (const detail of item.details ?? []) {
|
|
225
|
+
if (detail.result === 'PASS') continue
|
|
226
|
+
lines.push(` - ${detail.item}: ${detail.result} ${String(detail.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)}`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
135
231
|
export function formatText(report) {
|
|
136
232
|
const lines = []
|
|
137
|
-
const { subject, anchor, conclusion, coverage } = report
|
|
233
|
+
const { subject, anchor = {}, conclusion, coverage } = report
|
|
234
|
+
const byId = new Map((report.checks ?? []).map(item => [item.id, item]))
|
|
138
235
|
lines.push(`DS-C check — ${subject.name ?? '<unknown>'}@${subject.version ?? '?'} (${subject.kind}${subject.kindInferred ? ', inferred' : ''})`)
|
|
139
|
-
lines.push(`anchor: repo=${anchor.repo} commit=${anchor.commit.slice(0, 12)} dsh=${anchor.dsh} cordis=${anchor.cordis}`)
|
|
236
|
+
lines.push(`anchor: repo=${anchor.repo ?? '?'} commit=${String(anchor.commit ?? '').slice(0, 12)} dsh=${anchor.dsh ?? '?'} cordis=${anchor.cordis ?? '?'}${anchor.runtimeSource ? ` source=${anchor.runtimeSource}` : ''}${anchor.suspect ? ' suspect=true' : ''}`)
|
|
140
237
|
lines.push(`scope=${conclusion.scope} profile=${conclusion.profile} status=${conclusion.status} exit=${conclusion.exitCode}${conclusion.subset ? ` subset=${conclusion.subset}` : ''}${conclusion.staticOnly ? ' static-only' : ''}`)
|
|
141
238
|
if (conclusion.level) lines.push(`level: ${conclusion.level}`)
|
|
239
|
+
if (conclusion.headline) lines.push(`headline: ${conclusion.headline}`)
|
|
142
240
|
lines.push('')
|
|
143
|
-
lines
|
|
144
|
-
for (const item of report.checks) {
|
|
145
|
-
const reason = (item.reason || '').replace(/\s+/g, ' ').slice(0, 120)
|
|
146
|
-
lines.push(`${item.id.padEnd(7)} ${ICON[item.result] ?? item.result} ${item.clause.padEnd(6)} ${item.layer.padEnd(6)} ${reason}`)
|
|
147
|
-
for (const detail of item.details ?? []) {
|
|
148
|
-
if (detail.result === 'PASS') continue
|
|
149
|
-
lines.push(` - ${detail.item}: ${detail.result} ${String(detail.reason).slice(0, 140)}`)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
241
|
+
renderBucket(lines, 'Blocking (FAIL)', report.buckets?.blocking ?? [], byId)
|
|
152
242
|
lines.push('')
|
|
153
|
-
lines
|
|
243
|
+
renderBucket(lines, 'Hygiene (WARN)', report.buckets?.hygiene ?? [], byId)
|
|
244
|
+
lines.push('')
|
|
245
|
+
renderBucket(lines, 'Not checked (NA / not-run / ERROR)', report.buckets?.notChecked ?? [], byId)
|
|
246
|
+
lines.push('')
|
|
247
|
+
const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
|
|
248
|
+
lines.push(`passed (${passed.length}): ${passed.map(item => item.id).join(', ') || 'none'}`)
|
|
249
|
+
lines.push(`coverage: total=${coverage.total ?? report.checks.length} checked=${coverage.checked ?? coverage.pass + coverage.warn + coverage.fail} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun} error=${coverage.error} advisory=${coverage.advisory}`)
|
|
250
|
+
if (coverage.entryBlocking) {
|
|
251
|
+
const blocking = coverage.entryBlocking
|
|
252
|
+
lines.push(`entryBlocking: checked=${blocking.checked?.length ?? '?'}/${blocking.required} failed=[${(blocking.failed ?? []).join(', ')}] notChecked=[${(blocking.notChecked ?? []).join(', ')}] pass=${blocking.pass}`)
|
|
253
|
+
}
|
|
254
|
+
lines.push(`metrics: checked=${report.metrics?.checked ?? '?'}/${report.metrics?.total ?? '?'} declaredFields=${report.metrics?.declaredFields ?? 0}/5`)
|
|
154
255
|
if (conclusion.reason) lines.push(`reason: ${conclusion.reason}`)
|
|
155
256
|
if (conclusion.status === 'incomplete') lines.push('NOT A DS-C PASS: at least one mandatory check did not run (see notRun).')
|
|
156
257
|
if (Array.isArray(report.advice) && report.advice.length > 0) {
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox execution for FLOW evidence collection.
|
|
3
|
+
*
|
|
4
|
+
* `flow collect --sandbox` runs the runtime probe (which executes
|
|
5
|
+
* `dsh plugin add` and boots the tree) behind a containment layer:
|
|
6
|
+
*
|
|
7
|
+
* env allowlist from `sandbox-env.mjs`; GH_TOKEN / NPM_TOKEN /
|
|
8
|
+
* GITHUB_TOKEN are asserted absent, and any secret-shaped name is
|
|
9
|
+
* reported rather than silently forwarded;
|
|
10
|
+
* home caller-supplied temporary HOME/DSH_HOME/TMPDIR (never the real
|
|
11
|
+
* profile);
|
|
12
|
+
* network `unshare -n` when permitted, falling back to
|
|
13
|
+
* `unshare -Urn` (user+network namespace); if neither works the run
|
|
14
|
+
* continues degraded and records `unshare-missing`;
|
|
15
|
+
* limits `prlimit --nproc --as` when util-linux prlimit is available; the
|
|
16
|
+
* probe reads `/proc/self/limits` so only limits that actually took
|
|
17
|
+
* effect are recorded;
|
|
18
|
+
* timeout the child is spawned as its own process-group leader and the
|
|
19
|
+
* whole group receives SIGKILL, so `dsh` grandchildren die too
|
|
20
|
+
* (Windows: `taskkill /T /F`).
|
|
21
|
+
*
|
|
22
|
+
* This is containment, not a security boundary: no seccomp, no cgroup memory/
|
|
23
|
+
* pids accounting, no OCI image. Evidence is only allowed to call itself
|
|
24
|
+
* `sandbox` when every item above is effective; any missing item switches the
|
|
25
|
+
* producer to `sandbox-degraded`, which `level-audit` never reports as runtime.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs'
|
|
28
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
29
|
+
import { isSecretEnvName, sanitizedEnv } from './sandbox-env.mjs'
|
|
30
|
+
|
|
31
|
+
export const SANDBOX_REPORT_SCHEMA = 'dsh-sandbox-report/1'
|
|
32
|
+
export const SANDBOX_DEGRADED = Object.freeze({
|
|
33
|
+
unshare: 'unshare-missing',
|
|
34
|
+
prlimit: 'prlimit-missing',
|
|
35
|
+
env: 'env-token-present',
|
|
36
|
+
home: 'home-not-isolated',
|
|
37
|
+
treeKill: 'process-tree-kill-unavailable',
|
|
38
|
+
execution: 'sandbox-execution-failed',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** Names that must never reach a child that may import plugin code. */
|
|
42
|
+
export const CREDENTIAL_ENV_NAMES = Object.freeze(['GH_TOKEN', 'NPM_TOKEN', 'GITHUB_TOKEN'])
|
|
43
|
+
|
|
44
|
+
/** Inherited or caller-supplied names that look like credentials. */
|
|
45
|
+
export function credentialEnvOffenders(env = process.env, names = CREDENTIAL_ENV_NAMES) {
|
|
46
|
+
const offenders = new Set()
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
if (env && Object.prototype.hasOwnProperty.call(env, name) && env[name] !== undefined && env[name] !== null) {
|
|
49
|
+
offenders.add(name)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const name of Object.keys(env ?? {})) {
|
|
53
|
+
if (isSecretEnvName(name)) offenders.add(name)
|
|
54
|
+
}
|
|
55
|
+
return [...offenders].sort()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hard gate: refuse to boot a plugin when a credential survived scrubbing. */
|
|
59
|
+
export function assertNoCredentialEnv(env = process.env, names = CREDENTIAL_ENV_NAMES) {
|
|
60
|
+
const offenders = credentialEnvOffenders(env, names)
|
|
61
|
+
if (offenders.length > 0) {
|
|
62
|
+
throw new Error(`sandbox env assertion failed: ${offenders.join(', ')} present in the child environment`)
|
|
63
|
+
}
|
|
64
|
+
return { policy: 'allowlist', checked: [...names], absent: true, offenders: [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Count tasks (processes + threads) owned by this uid, for a safe RLIMIT_NPROC. */
|
|
68
|
+
export function userTaskCount(uid = typeof process.getuid === 'function' ? process.getuid() : null) {
|
|
69
|
+
if (process.platform !== 'linux' || uid === null) return 0
|
|
70
|
+
let tasks = 0
|
|
71
|
+
try {
|
|
72
|
+
for (const entry of fs.readdirSync('/proc')) {
|
|
73
|
+
if (!/^\d+$/.test(entry)) continue
|
|
74
|
+
try {
|
|
75
|
+
if (fs.statSync(`/proc/${entry}`).uid !== uid) continue
|
|
76
|
+
const status = fs.readFileSync(`/proc/${entry}/status`, 'utf8')
|
|
77
|
+
const match = /^Threads:\s+(\d+)$/m.exec(status)
|
|
78
|
+
tasks += match ? Number(match[1]) : 1
|
|
79
|
+
} catch { /* process vanished */ }
|
|
80
|
+
}
|
|
81
|
+
} catch { return 0 }
|
|
82
|
+
return tasks
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function defaultNproc() {
|
|
86
|
+
// RLIMIT_NPROC counts tasks, so a fixed small value (e.g. 64) breaks Node on
|
|
87
|
+
// a busy desktop. Keep headroom above the current task count.
|
|
88
|
+
return Math.max(1024, userTaskCount() + 512)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function probeFailure(error) {
|
|
92
|
+
if (!error) return null
|
|
93
|
+
return String(error.message ?? error).slice(0, 300)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Try a real network namespace. `unshare -n` needs CAP_SYS_ADMIN; in an
|
|
98
|
+
* unprivileged container/WSL that fails, so `unshare -Urn` (user namespace +
|
|
99
|
+
* network namespace) is the fallback. `true` is the probe target so no plugin
|
|
100
|
+
* code runs while deciding.
|
|
101
|
+
*/
|
|
102
|
+
export function probeNetworkIsolation({ platform = process.platform, unshare = 'unshare', timeoutMs = 5_000 } = {}) {
|
|
103
|
+
if (platform === 'win32') {
|
|
104
|
+
return {
|
|
105
|
+
isolated: false,
|
|
106
|
+
degraded: SANDBOX_DEGRADED.unshare,
|
|
107
|
+
reason: 'unshare is not available on win32',
|
|
108
|
+
attempts: [],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const attempts = []
|
|
112
|
+
for (const args of [['-n'], ['-Urn']]) {
|
|
113
|
+
const command = [unshare, ...args, 'true']
|
|
114
|
+
const result = spawnSync(unshare, [...args, 'true'], {
|
|
115
|
+
encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], timeout: timeoutMs, env: sanitizedEnv(),
|
|
116
|
+
})
|
|
117
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
118
|
+
const attempt = {
|
|
119
|
+
command,
|
|
120
|
+
exitCode,
|
|
121
|
+
signal: result.signal ?? null,
|
|
122
|
+
error: probeFailure(result.error),
|
|
123
|
+
stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
|
|
124
|
+
}
|
|
125
|
+
attempts.push(attempt)
|
|
126
|
+
if (!result.error && result.status === 0) {
|
|
127
|
+
return {
|
|
128
|
+
isolated: true,
|
|
129
|
+
mechanism: args[0] === '-n' ? 'unshare -n (network namespace)' : 'unshare -Urn (user+network namespace)',
|
|
130
|
+
bin: unshare,
|
|
131
|
+
args,
|
|
132
|
+
probe: attempt,
|
|
133
|
+
attempts,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
isolated: false,
|
|
139
|
+
degraded: SANDBOX_DEGRADED.unshare,
|
|
140
|
+
reason: 'unshare -n and unshare -Urn both failed; no network namespace',
|
|
141
|
+
attempts,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readLimitsProbe() {
|
|
146
|
+
return [
|
|
147
|
+
"const t = require('fs').readFileSync('/proc/self/limits', 'utf8')",
|
|
148
|
+
"const pick = (label) => { const m = new RegExp('^' + label + '\\\\s+(\\\\d+)', 'm').exec(t); return m ? Number(m[1]) : null }",
|
|
149
|
+
"console.log(JSON.stringify({ nproc: pick('Max processes'), as: pick('Max address space') }))",
|
|
150
|
+
].join(';')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply and verify `prlimit --nproc --as`. The probe prints the child's own
|
|
155
|
+
* `/proc/self/limits`, so `applied` lists effects, not intentions.
|
|
156
|
+
*/
|
|
157
|
+
export function probeLimits({
|
|
158
|
+
platform = process.platform,
|
|
159
|
+
prlimit = 'prlimit',
|
|
160
|
+
nproc = null,
|
|
161
|
+
asBytes = 4 * 1024 ** 3,
|
|
162
|
+
node = process.execPath,
|
|
163
|
+
timeoutMs = 15_000,
|
|
164
|
+
} = {}) {
|
|
165
|
+
if (platform === 'win32') {
|
|
166
|
+
return { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'prlimit is a util-linux tool; win32 has no equivalent here', applied: [] }
|
|
167
|
+
}
|
|
168
|
+
const requestedNproc = Number.isFinite(nproc) && nproc > 0 ? Math.trunc(nproc) : defaultNproc()
|
|
169
|
+
const requestedAs = Number.isFinite(asBytes) && asBytes > 0 ? Math.trunc(asBytes) : 4 * 1024 ** 3
|
|
170
|
+
const command = [prlimit, `--nproc=${requestedNproc}`, `--as=${requestedAs}`, '--', node, '-e', readLimitsProbe()]
|
|
171
|
+
const result = spawnSync(prlimit, command.slice(1), {
|
|
172
|
+
encoding: 'utf8', timeout: timeoutMs, env: sanitizedEnv(),
|
|
173
|
+
})
|
|
174
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
175
|
+
let observed = null
|
|
176
|
+
try {
|
|
177
|
+
const line = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop()
|
|
178
|
+
observed = line ? JSON.parse(line) : null
|
|
179
|
+
} catch { observed = null }
|
|
180
|
+
const applied = []
|
|
181
|
+
if (observed?.nproc === requestedNproc) applied.push('nproc')
|
|
182
|
+
if (observed?.as === requestedAs) applied.push('as')
|
|
183
|
+
const available = !result.error && exitCode === 0 && applied.length === 2
|
|
184
|
+
return {
|
|
185
|
+
available,
|
|
186
|
+
...(available ? {} : { degraded: SANDBOX_DEGRADED.prlimit }),
|
|
187
|
+
bin: prlimit,
|
|
188
|
+
nproc: requestedNproc,
|
|
189
|
+
asBytes: requestedAs,
|
|
190
|
+
applied,
|
|
191
|
+
observed,
|
|
192
|
+
probe: {
|
|
193
|
+
command,
|
|
194
|
+
exitCode,
|
|
195
|
+
signal: result.signal ?? null,
|
|
196
|
+
error: probeFailure(result.error),
|
|
197
|
+
stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
|
|
198
|
+
},
|
|
199
|
+
...(available ? {} : { reason: 'prlimit did not apply and verify nproc+as' }),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function processTreeKillCapability(platform = process.platform) {
|
|
204
|
+
if (platform === 'win32') {
|
|
205
|
+
const probe = spawnSync('taskkill', ['/?'], { stdio: 'ignore', timeout: 5_000 })
|
|
206
|
+
const available = !probe.error
|
|
207
|
+
return {
|
|
208
|
+
available,
|
|
209
|
+
mechanism: available ? 'taskkill /T /F' : 'taskkill-missing',
|
|
210
|
+
...(available ? {} : { degraded: SANDBOX_DEGRADED.treeKill, reason: 'taskkill is unavailable' }),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { available: true, mechanism: 'process-group SIGKILL (detached child, kill(-pgid))' }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the containment report for one collection. Degraded reasons are
|
|
218
|
+
* deduplicated; `mode` is `sandbox` only when the list is empty.
|
|
219
|
+
*/
|
|
220
|
+
export function buildSandboxPlan({
|
|
221
|
+
platform = process.platform,
|
|
222
|
+
env = process.env,
|
|
223
|
+
unshare = 'unshare',
|
|
224
|
+
prlimit = 'prlimit',
|
|
225
|
+
nproc = null,
|
|
226
|
+
asBytes = 4 * 1024 ** 3,
|
|
227
|
+
homeIsolated = true,
|
|
228
|
+
force = {},
|
|
229
|
+
} = {}) {
|
|
230
|
+
const degraded = []
|
|
231
|
+
const envOffenders = credentialEnvOffenders(env)
|
|
232
|
+
if (envOffenders.length > 0) degraded.push(SANDBOX_DEGRADED.env)
|
|
233
|
+
if (!homeIsolated) degraded.push(SANDBOX_DEGRADED.home)
|
|
234
|
+
|
|
235
|
+
const network = force.network
|
|
236
|
+
? { isolated: false, degraded: SANDBOX_DEGRADED.unshare, reason: 'network isolation disabled by caller', attempts: [] }
|
|
237
|
+
: probeNetworkIsolation({ platform, unshare })
|
|
238
|
+
if (!network.isolated) degraded.push(network.degraded ?? SANDBOX_DEGRADED.unshare)
|
|
239
|
+
|
|
240
|
+
const limits = force.limits
|
|
241
|
+
? { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'rlimits disabled by caller', applied: [] }
|
|
242
|
+
: probeLimits({ platform, prlimit, nproc, asBytes })
|
|
243
|
+
if (!limits.available) degraded.push(limits.degraded ?? SANDBOX_DEGRADED.prlimit)
|
|
244
|
+
|
|
245
|
+
const processTreeKill = processTreeKillCapability(platform)
|
|
246
|
+
if (!processTreeKill.available) degraded.push(processTreeKill.degraded ?? SANDBOX_DEGRADED.treeKill)
|
|
247
|
+
|
|
248
|
+
const unique = [...new Set(degraded)]
|
|
249
|
+
return {
|
|
250
|
+
schema: SANDBOX_REPORT_SCHEMA,
|
|
251
|
+
mode: unique.length === 0 ? 'sandbox' : 'sandbox-degraded',
|
|
252
|
+
platform,
|
|
253
|
+
env: {
|
|
254
|
+
policy: 'allowlist',
|
|
255
|
+
checked: [...CREDENTIAL_ENV_NAMES],
|
|
256
|
+
absent: envOffenders.length === 0,
|
|
257
|
+
offenders: envOffenders,
|
|
258
|
+
},
|
|
259
|
+
home: { isolated: homeIsolated, mode: 'temporary HOME/DSH_HOME/TMPDIR' },
|
|
260
|
+
network,
|
|
261
|
+
limits,
|
|
262
|
+
processTreeKill,
|
|
263
|
+
degraded: unique,
|
|
264
|
+
note: 'containment only: no seccomp, no cgroup memory/pids accounting, no OCI image; read access is not restricted',
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Wrap `command args...` in the effective prlimit + unshare prefixes. */
|
|
269
|
+
export function sandboxArgv(plan, command, args = []) {
|
|
270
|
+
let argv = [command, ...args]
|
|
271
|
+
if (plan?.limits?.available) {
|
|
272
|
+
argv = [plan.limits.bin, `--nproc=${plan.limits.nproc}`, `--as=${plan.limits.asBytes}`, '--', ...argv]
|
|
273
|
+
}
|
|
274
|
+
if (plan?.network?.isolated) {
|
|
275
|
+
argv = [plan.network.bin, ...plan.network.args, ...argv]
|
|
276
|
+
}
|
|
277
|
+
return argv
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function groupAlive(pgid, platform) {
|
|
281
|
+
try {
|
|
282
|
+
process.kill(platform === 'win32' ? pgid : -pgid, 0)
|
|
283
|
+
return true
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return error?.code === 'EPERM'
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function waitForGroupExit(pgid, platform, timeoutMs) {
|
|
290
|
+
return new Promise((resolve) => {
|
|
291
|
+
const deadline = Date.now() + timeoutMs
|
|
292
|
+
const tick = () => {
|
|
293
|
+
if (!groupAlive(pgid, platform)) { resolve(true); return }
|
|
294
|
+
if (Date.now() >= deadline) { resolve(false); return }
|
|
295
|
+
setTimeout(tick, 50)
|
|
296
|
+
}
|
|
297
|
+
tick()
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Terminate the child *and its whole process group* (never just the parent). */
|
|
302
|
+
export function killProcessTree(pid, { platform = process.platform, signal = 'SIGKILL' } = {}) {
|
|
303
|
+
if (!pid) return { mechanism: 'none', signalled: false, pid: null }
|
|
304
|
+
if (platform === 'win32') {
|
|
305
|
+
const result = spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 10_000 })
|
|
306
|
+
return { mechanism: 'taskkill /pid <pid> /T /F', signalled: !result.error && result.status === 0, pid, exitCode: result.status ?? null }
|
|
307
|
+
}
|
|
308
|
+
let signalled = false
|
|
309
|
+
try { process.kill(-pid, signal); signalled = true } catch { /* group already gone */ }
|
|
310
|
+
try { process.kill(pid, signal); signalled = true } catch { /* parent already gone */ }
|
|
311
|
+
return { mechanism: `process-group ${signal}`, signalled, pid, pgid: pid }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Spawn and supervise one command. On timeout the process group is SIGKILLed
|
|
316
|
+
* and `treeKill.cleared` is verified before resolving.
|
|
317
|
+
*/
|
|
318
|
+
export function runInProcessTree({
|
|
319
|
+
command,
|
|
320
|
+
args = [],
|
|
321
|
+
cwd = process.cwd(),
|
|
322
|
+
env = sanitizedEnv(),
|
|
323
|
+
timeoutMs = 120_000,
|
|
324
|
+
platform = process.platform,
|
|
325
|
+
maxBufferBytes = 8 * 1024 * 1024,
|
|
326
|
+
killGraceMs = 2_000,
|
|
327
|
+
} = {}) {
|
|
328
|
+
return new Promise((resolve) => {
|
|
329
|
+
const startedAt = new Date().toISOString()
|
|
330
|
+
const t0 = Date.now()
|
|
331
|
+
let child
|
|
332
|
+
try {
|
|
333
|
+
child = spawn(command, args, {
|
|
334
|
+
cwd,
|
|
335
|
+
env,
|
|
336
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
337
|
+
detached: platform !== 'win32',
|
|
338
|
+
windowsHide: true,
|
|
339
|
+
})
|
|
340
|
+
} catch (error) {
|
|
341
|
+
resolve({
|
|
342
|
+
command: [command, ...args], exitCode: null, signal: null, killed: false, timedOut: false,
|
|
343
|
+
stdout: '', stderr: `spawn error: ${error.message}`, startedAt, endedAt: new Date().toISOString(),
|
|
344
|
+
durationMs: Date.now() - t0, treeKill: null, spawnError: error.message,
|
|
345
|
+
})
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
let stdout = ''
|
|
349
|
+
let stderr = ''
|
|
350
|
+
let stdoutBytes = 0
|
|
351
|
+
let stderrBytes = 0
|
|
352
|
+
let timedOut = false
|
|
353
|
+
let treeKill = null
|
|
354
|
+
let settled = false
|
|
355
|
+
let timer = null
|
|
356
|
+
const append = (chunk, stream) => {
|
|
357
|
+
const text = String(chunk)
|
|
358
|
+
if (stream === 'stdout') {
|
|
359
|
+
if (stdoutBytes < maxBufferBytes) stdout += text.slice(0, maxBufferBytes - stdoutBytes)
|
|
360
|
+
stdoutBytes += Buffer.byteLength(text)
|
|
361
|
+
} else {
|
|
362
|
+
if (stderrBytes < maxBufferBytes) stderr += text.slice(0, maxBufferBytes - stderrBytes)
|
|
363
|
+
stderrBytes += Buffer.byteLength(text)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const finalize = async (code, signal) => {
|
|
367
|
+
if (settled) return
|
|
368
|
+
settled = true
|
|
369
|
+
clearTimeout(timer)
|
|
370
|
+
if (treeKill) {
|
|
371
|
+
treeKill.cleared = await waitForGroupExit(child.pid, platform, killGraceMs)
|
|
372
|
+
}
|
|
373
|
+
resolve({
|
|
374
|
+
command: [command, ...args],
|
|
375
|
+
exitCode: Number.isInteger(code) ? code : timedOut ? 124 : null,
|
|
376
|
+
signal: signal ?? null,
|
|
377
|
+
killed: Boolean(treeKill),
|
|
378
|
+
timedOut,
|
|
379
|
+
stdout,
|
|
380
|
+
stderr,
|
|
381
|
+
startedAt,
|
|
382
|
+
endedAt: new Date().toISOString(),
|
|
383
|
+
durationMs: Date.now() - t0,
|
|
384
|
+
treeKill,
|
|
385
|
+
stdoutTruncated: stdoutBytes > maxBufferBytes,
|
|
386
|
+
stderrTruncated: stderrBytes > maxBufferBytes,
|
|
387
|
+
pid: child.pid ?? null,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
child.stdout?.on('data', (chunk) => append(chunk, 'stdout'))
|
|
391
|
+
child.stderr?.on('data', (chunk) => append(chunk, 'stderr'))
|
|
392
|
+
child.on('error', (error) => {
|
|
393
|
+
stderr += `\n[spawn error] ${error.message}`
|
|
394
|
+
void finalize(null, null)
|
|
395
|
+
})
|
|
396
|
+
child.on('close', (code, signal) => { void finalize(code, signal) })
|
|
397
|
+
timer = setTimeout(() => {
|
|
398
|
+
timedOut = true
|
|
399
|
+
treeKill = killProcessTree(child.pid, { platform })
|
|
400
|
+
}, timeoutMs)
|
|
401
|
+
timer.unref?.()
|
|
402
|
+
})
|
|
403
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 1.3.0 — 2026-11-06
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- `check [dir]` 子命令:默认 cwd、Markdown 报告(一句结论、≤3 条优先修复、分桶全表、advice、免责声明),`--json` 与 `--html`(单文件、内联 CSS、零依赖)。
|
|
6
|
+
- `spec/schemas/advice.schema.json`(`dsh-advice/1`),advice 逐条过 schema。
|
|
7
|
+
- 证据主体绑定:`--flow-evidence --dir` 校验 name/version/treeSha256/tarball 哈希,不一致即 `invalid`。
|
|
8
|
+
- CI 归属:collector 写 `producer.runUrl/repository/commit`,audit 标 `attestation: self-declared|ci`(不升级 claimLevel)。
|
|
9
|
+
- sidecar 生成(`publish/sidecar.json`,`dsh-market-sidecar/1`)、每日过期/撤回 workflow、离线包(~580 KB)。
|
|
10
|
+
- `SUPPORTED.md`(host × node × checker 矩阵 + 证据锚)与 `CHANGELOG-RULES.md`(规则级变更与影响面)。
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- coverage 按全部 14 项统计,`na/notRun/error` 不再为 0;未查 C-3.1/3.2/3.5 时 `entryBlocking.pass=false`,并输出 `checked/notChecked`。
|
|
15
|
+
- 默认 runtime 锚改为宿主实际版本(`anchor.runtimeSource=host`),与 `--runtime-version` 不一致时 `anchor.suspect=true`;不再固定 `0.2.2-alpha.2`。
|
|
16
|
+
- 报告分三桶:`blocking`/`hygiene`(DSC-11/12/13 声明类)/`notChecked`,text 分组渲染,JSON 加 `buckets/metrics/headline`。
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
|
|
20
|
+
- `npm test` 193/193(含 root `test/`)。
|
|
21
|
+
|
|
22
|
+
## 1.2.2 — 2026-11-06
|
|
23
|
+
|
|
24
|
+
- FLOW 沙箱:`flow collect --sandbox` 在 `unshare -Urn` + `prlimit` 下运行,记录 `sandbox{}`;env 白名单硬断言 token 不在子进程;进程组超时击杀;`producer.kind=sandbox` 仅当隔离项全部生效,否则 `sandbox-degraded` 且 audit 永不 runtime。测试 152 → 159。
|
|
25
|
+
- 发布包:`npm test` 改为 `run-tests.mjs`,未随包分发的 test 目录不再导致安装后测试失败。
|
|
26
|
+
|
|
1
27
|
## 1.2.1 — 2026-11-06
|
|
2
28
|
|
|
3
29
|
- **V2 核验修复**:`level-audit` 不再把结构自洽的 self-attested/ci-attested `dsh-flow-evidence/2` 判成 FLOW `runtime`。只有 `claims.claimLevel=sandbox-attested` 或 `producer.kind=sandbox` 才允许 `runtime`;self/ci 的 `passed` 计数保留但 state 记 `not-run`,reason 为 `<claimLevel> evidence; runtime requires sandbox-attested`。`spec/flow-evidence.schema.json` 的 `claims`/`producer.kind` 补充了该判定说明。`advice[]` 新增 `gate`(该 check 的最终结果 PASS/WARN/FAIL),`severity` 跟随 `gate`:ENTRY static claim 把 DSC-02 降为 WARN 时,C-2.1 的 FAIL detail 仍保留在 `reason`,但 advice severity 为 WARN。测试 150 → 152。
|