dsh-standards 1.4.1 → 1.6.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/fixtures/security/sec15-clean/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec15-missing-ref/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-missing-ref/package.json +22 -0
- package/40-checker/fixtures/security/sec15-node/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-node/package.json +22 -0
- package/40-checker/fixtures/security/sec15-node/scripts/build.mjs +1 -0
- package/40-checker/fixtures/security/sec15-pipe/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-pipe/package.json +22 -0
- package/40-checker/fixtures/security/sec16-client/index.mjs +3 -0
- package/40-checker/fixtures/security/sec16-client/lib/client.js +3 -0
- package/40-checker/fixtures/security/sec16-client/package.json +21 -0
- package/40-checker/fixtures/security/sec16-declared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec16-declared/package.json +19 -0
- package/40-checker/fixtures/security/sec16-eval/index.mjs +3 -0
- package/40-checker/fixtures/security/sec16-eval/package.json +19 -0
- package/40-checker/fixtures/security/sec16-require/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-require/package.json +19 -0
- package/40-checker/fixtures/security/sec17-env/index.mjs +5 -0
- package/40-checker/fixtures/security/sec17-env/package.json +19 -0
- package/40-checker/fixtures/security/sec17-seam/index.mjs +3 -0
- package/40-checker/fixtures/security/sec17-seam/package.json +19 -0
- package/40-checker/fixtures/security/sec17-ssh/index.mjs +6 -0
- package/40-checker/fixtures/security/sec17-ssh/package.json +19 -0
- package/40-checker/fixtures/security/sec18-bad/index.mjs +3 -0
- package/40-checker/fixtures/security/sec18-bad/package.json +21 -0
- package/40-checker/fixtures/security/sec18-good/index.mjs +3 -0
- package/40-checker/fixtures/security/sec18-good/package.json +26 -0
- package/40-checker/fixtures/security/sec18-undeclared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec18-undeclared/package.json +26 -0
- package/40-checker/fixtures/security/sec19-blob/index.mjs +2 -0
- package/40-checker/fixtures/security/sec19-blob/package.json +19 -0
- package/40-checker/fixtures/security/sec19-clean/index.mjs +2 -0
- package/40-checker/fixtures/security/sec19-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec19-wasm/blob.wasm +0 -0
- package/40-checker/fixtures/security/sec19-wasm/index.mjs +3 -0
- package/40-checker/fixtures/security/sec19-wasm/package.json +19 -0
- package/40-checker/fixtures/security/sec20-unwrapped/index.mjs +4 -0
- package/40-checker/fixtures/security/sec20-unwrapped/package.json +23 -0
- package/40-checker/fixtures/security/sec20-wrapped/index.mjs +3 -0
- package/40-checker/fixtures/security/sec20-wrapped/package.json +23 -0
- package/40-checker/fixtures/security/sec21-known/index.mjs +3 -0
- package/40-checker/fixtures/security/sec21-known/package.json +23 -0
- package/40-checker/fixtures/security/sec21-typosquat/index.mjs +3 -0
- package/40-checker/fixtures/security/sec21-typosquat/package.json +22 -0
- package/40-checker/fixtures/security/sec22-attested/index.mjs +3 -0
- package/40-checker/fixtures/security/sec22-attested/package.json +22 -0
- package/40-checker/fixtures/security/sec22-none/index.mjs +3 -0
- package/40-checker/fixtures/security/sec22-none/package.json +19 -0
- package/40-checker/scripts/sign-evidence.mjs +169 -0
- package/40-checker/src/cli.mjs +402 -73
- package/40-checker/src/declarations.mjs +10 -10
- package/40-checker/src/flow-evidence.mjs +21 -0
- package/40-checker/src/index.mjs +2 -1
- package/40-checker/src/report.mjs +116 -4
- package/40-checker/src/runtime-child.mjs +68 -0
- package/40-checker/src/runtime.mjs +3 -1
- package/40-checker/src/sandbox-run.mjs +406 -35
- package/40-checker/src/security.mjs +1065 -0
- package/40-checker/src/signature.mjs +249 -0
- package/40-checker/src/util.mjs +53 -0
- package/CHANGELOG.md +34 -0
- package/docs/SIGNING.md +137 -0
- package/docs/coverage.md +39 -6
- package/package.json +1 -1
- package/spec/coverage-contract.md +33 -11
- package/spec/dsc-clauses.json +410 -9
- package/spec/remediation.json +196 -0
- package/spec/schemas/conformance.schema.json +80 -2
package/40-checker/src/cli.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
|
|
5
6
|
import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
|
|
7
|
+
import { runSecurityChecks } from './security.mjs'
|
|
6
8
|
import { runRuntimeChecks } from './runtime.mjs'
|
|
7
9
|
import { runStaticBundleChecks } from './bundle-static.mjs'
|
|
8
10
|
import { spawn, spawnSync } from 'node:child_process'
|
|
@@ -31,9 +33,11 @@ Usage:
|
|
|
31
33
|
|
|
32
34
|
Options:
|
|
33
35
|
--format text|json|sarif output format (default: text)
|
|
34
|
-
--scope mvp|ds-c|entry
|
|
36
|
+
--scope mvp|ds-c|entry|security mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate;
|
|
37
|
+
security = DSC-15/16/17/21 are blocking, DSC-18/19/20/22 advisory
|
|
35
38
|
--profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
|
|
36
|
-
--runtime
|
|
39
|
+
--runtime [path|auto] built @deepseek-ai/cordis lib/index.js (or its package dir); bare or
|
|
40
|
+
'auto' resolves from the installed dsh; enables real L1-L3
|
|
37
41
|
--runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
|
|
38
42
|
--cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
|
|
39
43
|
--commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
|
|
@@ -48,7 +52,9 @@ Options:
|
|
|
48
52
|
--treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
|
|
49
53
|
--entry-runtime require|server-entry entry scope: require full G0-G3 (default) or accept server-entry proxy (not bundle)
|
|
50
54
|
--entry-claim runtime|static entry scope: runtime (default, needs DSC-14) or static recognized (no runtime claim)
|
|
51
|
-
--host
|
|
55
|
+
--host [file|auto] app-boot lib/index.js (or its package dir); bare or 'auto'
|
|
56
|
+
resolves from the installed dsh; enables real bundle G0-G3 smoke.
|
|
57
|
+
Nothing executes unless this or --runtime is given.
|
|
52
58
|
--base <file> optional base cordis.patch.yml for bundle smoke
|
|
53
59
|
--web <file> optional web-app cordis.patch.yml for bundle smoke
|
|
54
60
|
--bare <dir> optional bare-module base for bundle smoke (host farm)
|
|
@@ -117,7 +123,14 @@ function parseArgs(argv) {
|
|
|
117
123
|
case '--format': options.format = value(); break
|
|
118
124
|
case '--scope': options.scope = value(); break
|
|
119
125
|
case '--profile': options.profile = value(); break
|
|
120
|
-
case '--runtime':
|
|
126
|
+
case '--runtime': {
|
|
127
|
+
if (inline !== undefined) { options.runtime = inline; break }
|
|
128
|
+
const next = argv[index + 1]
|
|
129
|
+
if (next === undefined || next.startsWith('--')) { options.runtime = 'auto'; break }
|
|
130
|
+
index += 1
|
|
131
|
+
options.runtime = next
|
|
132
|
+
break
|
|
133
|
+
}
|
|
121
134
|
case '--runtime-version': options.runtimeVersion = value(); break
|
|
122
135
|
case '--cordis-version': options.cordisVersion = value(); break
|
|
123
136
|
case '--commit': options.commit = value(); break
|
|
@@ -133,7 +146,14 @@ function parseArgs(argv) {
|
|
|
133
146
|
case '--treat-as-plugin': options.treatAsPlugin = true; break
|
|
134
147
|
case '--entry-runtime': options.entryRuntime = value(); break
|
|
135
148
|
case '--entry-claim': options.entryClaim = value(); break
|
|
136
|
-
case '--host':
|
|
149
|
+
case '--host': {
|
|
150
|
+
if (inline !== undefined) { options.host = inline; break }
|
|
151
|
+
const next = argv[index + 1]
|
|
152
|
+
if (next === undefined || next.startsWith('--')) { options.host = 'auto'; break }
|
|
153
|
+
index += 1
|
|
154
|
+
options.host = next
|
|
155
|
+
break
|
|
156
|
+
}
|
|
137
157
|
case '--base': options.base = value(); break
|
|
138
158
|
case '--web': options.web = value(); break
|
|
139
159
|
case '--bare': options.bare = value(); break
|
|
@@ -143,10 +163,10 @@ function parseArgs(argv) {
|
|
|
143
163
|
}
|
|
144
164
|
}
|
|
145
165
|
if (!['text', 'json', 'sarif'].includes(options.format)) throw new Error(`unsupported --format: ${options.format}`)
|
|
146
|
-
if (!['mvp', 'ds-c', 'entry'].includes(options.scope)) throw new Error(`unsupported --scope: ${options.scope}`)
|
|
166
|
+
if (!['mvp', 'ds-c', 'entry', 'security'].includes(options.scope)) throw new Error(`unsupported --scope: ${options.scope}`)
|
|
147
167
|
if (!['require', 'server-entry'].includes(options.entryRuntime)) throw new Error('--entry-runtime must be require or server-entry')
|
|
148
168
|
if (!['runtime', 'static'].includes(options.entryClaim)) throw new Error('--entry-claim must be runtime or static')
|
|
149
|
-
if (options.staticOnly) { options.scope = 'ds-c'; options.profile = 'listing' }
|
|
169
|
+
if (options.staticOnly) { if (options.scope !== 'security') options.scope = 'ds-c'; options.profile = 'listing' }
|
|
150
170
|
if (!options.profile) options.profile = options.marketIndex ? 'listing' : 'package'
|
|
151
171
|
if (!['package', 'listing', 'both'].includes(options.profile)) throw new Error(`unsupported --profile: ${options.profile}`)
|
|
152
172
|
if (options.staticOnly && options.treatAsPlugin) throw new Error('--static-only and --treat-as-plugin are mutually exclusive')
|
|
@@ -168,77 +188,283 @@ function runtimeNotRun(reason) {
|
|
|
168
188
|
}))
|
|
169
189
|
}
|
|
170
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Resolve an installed dsh's own package root. `--runtime auto` / `--host auto`
|
|
193
|
+
* use this so a real run needs no hand-copied path. The launcher may be a shim
|
|
194
|
+
* (`dsh`, `dsh.cmd`, `dsh.ps1`); its directory is the npm global prefix, and
|
|
195
|
+
* the package lives under node_modules/@deepseek-ai/dsh.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveDshInstall({ env = process.env, bin = null } = {}) {
|
|
198
|
+
const candidates = []
|
|
199
|
+
if (typeof env.DSH_INSTALL_DIR === 'string' && env.DSH_INSTALL_DIR.trim() !== '') {
|
|
200
|
+
candidates.push(env.DSH_INSTALL_DIR.trim())
|
|
201
|
+
}
|
|
202
|
+
const launcher = bin || env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
|
|
203
|
+
const launcherPaths = []
|
|
204
|
+
if (path.isAbsolute(launcher) || launcher.includes('/') || launcher.includes('\\')) {
|
|
205
|
+
launcherPaths.push(launcher)
|
|
206
|
+
} else {
|
|
207
|
+
// `which dsh` is not available in every child env; walk PATH directly so
|
|
208
|
+
// `--runtime auto` also works when dsh is only reachable as a command name.
|
|
209
|
+
const separator = process.platform === 'win32' ? ';' : ':'
|
|
210
|
+
for (const dir of String(env.PATH ?? '').split(separator)) {
|
|
211
|
+
if (!dir) continue
|
|
212
|
+
for (const name of process.platform === 'win32' ? [`${launcher}.cmd`, `${launcher}.ps1`, launcher] : [launcher]) {
|
|
213
|
+
const candidate = path.join(dir, name)
|
|
214
|
+
try { if (fs.existsSync(candidate)) launcherPaths.push(candidate) } catch { /* ignore */ }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const launcherPath of launcherPaths) {
|
|
219
|
+
const absolute = (() => { try { return fs.realpathSync(launcherPath) } catch { return path.resolve(launcherPath) } })()
|
|
220
|
+
candidates.push(path.join(path.dirname(absolute), 'node_modules', '@deepseek-ai', 'dsh'))
|
|
221
|
+
}
|
|
222
|
+
for (const prefix of [env.APPDATA && path.join(env.APPDATA, 'npm'), env.npm_config_prefix, '/usr/local', '/usr']) {
|
|
223
|
+
if (!prefix) continue
|
|
224
|
+
candidates.push(path.join(prefix, 'node_modules', '@deepseek-ai', 'dsh'))
|
|
225
|
+
}
|
|
226
|
+
for (const candidate of candidates) {
|
|
227
|
+
try {
|
|
228
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate
|
|
229
|
+
} catch { /* unreadable candidate */ }
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The file a `--runtime`/`--host` value points at. Directories are expanded to
|
|
236
|
+
* their package entry, and `auto` is resolved from the installed dsh. A value
|
|
237
|
+
* that stays missing is returned as-is on purpose: the run then fails with a
|
|
238
|
+
* recorded reason instead of pretending the check passed.
|
|
239
|
+
*/
|
|
240
|
+
export function resolveRuntimeTarget(value, kind = 'cordis', { env = process.env } = {}) {
|
|
241
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
242
|
+
const raw = value.trim()
|
|
243
|
+
if (raw === 'auto' || raw === 'host') {
|
|
244
|
+
const install = resolveDshInstall({ env })
|
|
245
|
+
if (!install) return null
|
|
246
|
+
const name = kind === 'host' ? 'dsh-app-boot' : 'cordis'
|
|
247
|
+
const candidates = [
|
|
248
|
+
path.join(install, 'node_modules', '@deepseek-ai', name, 'lib', 'index.js'),
|
|
249
|
+
// npm may hoist the package next to @deepseek-ai/dsh instead of nesting it.
|
|
250
|
+
path.join(install, '..', '..', '@deepseek-ai', name, 'lib', 'index.js'),
|
|
251
|
+
]
|
|
252
|
+
for (const candidate of candidates) {
|
|
253
|
+
try { if (fs.existsSync(candidate)) return path.resolve(candidate) } catch { /* keep looking */ }
|
|
254
|
+
}
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
const absolute = path.resolve(raw)
|
|
258
|
+
try {
|
|
259
|
+
if (fs.statSync(absolute).isDirectory()) return path.join(absolute, 'lib', 'index.js')
|
|
260
|
+
} catch { /* not a directory; treat as a file */ }
|
|
261
|
+
return absolute
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A disposable HOME/DSH_HOME/TMPDIR for one explicit runtime run. */
|
|
265
|
+
export function createRuntimeIsolation(base = os.tmpdir()) {
|
|
266
|
+
const root = fs.mkdtempSync(path.join(base, 'dsh-checker-runtime-'))
|
|
267
|
+
const home = path.join(root, 'home')
|
|
268
|
+
const tmp = path.join(root, 'tmp')
|
|
269
|
+
fs.mkdirSync(home, { recursive: true })
|
|
270
|
+
fs.mkdirSync(tmp, { recursive: true })
|
|
271
|
+
let cleaned = false
|
|
272
|
+
return {
|
|
273
|
+
root,
|
|
274
|
+
home,
|
|
275
|
+
tmp,
|
|
276
|
+
cleanup() {
|
|
277
|
+
if (cleaned) return
|
|
278
|
+
cleaned = true
|
|
279
|
+
try { fs.rmSync(root, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The only environment a child that may import plugin code receives. */
|
|
285
|
+
export function isolationEnv(isolation) {
|
|
286
|
+
if (!isolation) return {}
|
|
287
|
+
return {
|
|
288
|
+
HOME: isolation.home,
|
|
289
|
+
USERPROFILE: isolation.home,
|
|
290
|
+
DSH_HOME: isolation.home,
|
|
291
|
+
TMPDIR: isolation.tmp,
|
|
292
|
+
TEMP: isolation.tmp,
|
|
293
|
+
TMP: isolation.tmp,
|
|
294
|
+
XDG_CONFIG_HOME: path.join(isolation.home, '.config'),
|
|
295
|
+
XDG_DATA_HOME: path.join(isolation.home, '.local', 'share'),
|
|
296
|
+
XDG_CACHE_HOME: path.join(isolation.home, '.cache'),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const RUNTIME_MARKER = '__DSH_RUNTIME_CHECKS__'
|
|
301
|
+
|
|
302
|
+
function parseRuntimeOutput(text) {
|
|
303
|
+
const index = text.lastIndexOf(RUNTIME_MARKER)
|
|
304
|
+
if (index >= 0) {
|
|
305
|
+
const line = text.slice(index + RUNTIME_MARKER.length).split('\n', 1)[0]
|
|
306
|
+
try { return JSON.parse(line) } catch { /* fall through to the raw form */ }
|
|
307
|
+
}
|
|
308
|
+
const raw = text.trim()
|
|
309
|
+
if (raw.startsWith('{')) {
|
|
310
|
+
try { return JSON.parse(raw) } catch { /* not complete yet */ }
|
|
311
|
+
}
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
171
315
|
/**
|
|
172
316
|
* L1-L3 run in a child process so that a plugin cannot block the event loop,
|
|
173
|
-
* read the checker's environment, or kill the process. The child
|
|
174
|
-
*
|
|
317
|
+
* read the checker's environment, or kill the process. The child writes one
|
|
318
|
+
* marker line as soon as the checks finish; the parent resolves on that line
|
|
319
|
+
* and terminates the child, so a plugin that leaves timers behind cannot turn
|
|
320
|
+
* a completed run into "killed after timeout". A kill before the marker is
|
|
321
|
+
* reported as not-run, never as a pass.
|
|
175
322
|
*/
|
|
176
323
|
function isolatedRuntimeChecks(input) {
|
|
177
324
|
return new Promise((resolve) => {
|
|
178
|
-
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
|
|
325
|
+
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'runtime-child.mjs')
|
|
326
|
+
const startedAt = Date.now()
|
|
179
327
|
// The child imports the inspected package: never inherit the caller's env.
|
|
180
|
-
const child = spawn(process.execPath, [script], {
|
|
328
|
+
const child = spawn(process.execPath, [script], {
|
|
329
|
+
// fd 3 is the result pipe: plugin stdout/stderr cannot forge the payload.
|
|
330
|
+
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
|
331
|
+
env: sanitizedEnv(isolationEnv(input.isolation)),
|
|
332
|
+
})
|
|
181
333
|
let out = ''
|
|
182
334
|
let err = ''
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
335
|
+
let result = ''
|
|
336
|
+
const RESULT_LIMIT = 4 * 1024 * 1024
|
|
337
|
+
let settled = false
|
|
338
|
+
let timer = null
|
|
339
|
+
const finish = (payload, failure = null) => {
|
|
340
|
+
if (settled) return
|
|
341
|
+
settled = true
|
|
342
|
+
if (timer) clearTimeout(timer)
|
|
343
|
+
try { child.kill('SIGKILL') } catch { /* already gone */ }
|
|
344
|
+
const wallMs = Date.now() - startedAt
|
|
345
|
+
if (!payload) {
|
|
346
|
+
resolve({ checks: runtimeNotRun(failure), durationMs: wallMs, error: failure })
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
const error = payload.error ? String(payload.error).slice(0, 500) : null
|
|
350
|
+
const checks = Array.isArray(payload.checks) && payload.checks.length > 0 ? payload.checks : runtimeNotRun(error || 'L1 child produced no checks')
|
|
351
|
+
resolve({ checks, durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : wallMs, error })
|
|
352
|
+
}
|
|
353
|
+
timer = setTimeout(() => {
|
|
354
|
+
finish(null, `L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`)
|
|
355
|
+
}, input.timeoutMs + 3000)
|
|
356
|
+
child.stdout.on('data', (chunk) => {
|
|
357
|
+
if (out.length < RESULT_LIMIT) out += chunk
|
|
189
358
|
})
|
|
359
|
+
child.stdio[3]?.on('data', (chunk) => {
|
|
360
|
+
if (result.length >= RESULT_LIMIT) return
|
|
361
|
+
result += chunk
|
|
362
|
+
const payload = parseRuntimeOutput(result)
|
|
363
|
+
if (payload) finish(payload)
|
|
364
|
+
})
|
|
365
|
+
child.stderr.on('data', (chunk) => {
|
|
366
|
+
if (err.length < RESULT_LIMIT) err += chunk
|
|
367
|
+
})
|
|
368
|
+
child.on('error', (error) => finish(null, `L1 child failed to start: ${error.message}`))
|
|
190
369
|
child.on('exit', (code, signal) => {
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
const parsed = JSON.parse(out)
|
|
197
|
-
resolve(parsed.checks)
|
|
198
|
-
} catch {
|
|
199
|
-
resolve(runtimeNotRun(`L1 child produced no JSON (exit ${code}): ${String(err).slice(0, 200)}`))
|
|
200
|
-
}
|
|
370
|
+
const payload = parseRuntimeOutput(result) ?? parseRuntimeOutput(out)
|
|
371
|
+
if (payload) return finish(payload)
|
|
372
|
+
if (signal === 'SIGKILL' || signal === 'SIGTERM') return finish(null, `L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`)
|
|
373
|
+
finish(null, `L1 child produced no JSON (exit ${code}): ${String(err).slice(0, 200)}`)
|
|
201
374
|
})
|
|
202
375
|
child.stdin.end(JSON.stringify(input))
|
|
203
376
|
})
|
|
204
377
|
}
|
|
205
378
|
|
|
206
|
-
async function entryRunCheck(kind, runtimeChecks, options, dir) {
|
|
379
|
+
async function entryRunCheck(kind, runtimeChecks, options, dir, context = {}) {
|
|
207
380
|
const byId = new Map(runtimeChecks.map(item => [item.id, item]))
|
|
208
381
|
const c4 = byId.get('DSC-04')
|
|
382
|
+
const runtimeKind = context.runtimeKind ?? kind
|
|
383
|
+
const runtimeUsable = context.runtimeUsable === true
|
|
384
|
+
const noRuntime = { executed: false, modes: [], durationMs: null, failure: null }
|
|
209
385
|
const base = {
|
|
210
386
|
id: 'DSC-14', clause: 'C-14', layer: 'L1', method: 'M', advisory: false,
|
|
211
387
|
details: [], evidence: c4?.evidence ?? [],
|
|
212
388
|
}
|
|
213
389
|
if (options.entryClaim === 'static') {
|
|
214
|
-
return { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }
|
|
390
|
+
return { check: { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }, runtime: noRuntime }
|
|
215
391
|
}
|
|
216
392
|
if (['client', 'preset', 'skill', 'theme', 'mcp-config', 'unknown'].includes(kind)) {
|
|
217
|
-
return { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }
|
|
393
|
+
return { check: { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }, runtime: noRuntime }
|
|
218
394
|
}
|
|
219
395
|
if (kind === 'bundle' && options.entryRuntime !== 'server-entry') {
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
396
|
+
if (options.host) {
|
|
397
|
+
const startedAt = Date.now()
|
|
398
|
+
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'bundle-smoke.mjs')
|
|
399
|
+
const smokeTimeout = Math.max(options.timeoutMs, 5000)
|
|
400
|
+
const args = [script, '--package', dir, '--host', options.host, '--json', '--timeout', String(smokeTimeout)]
|
|
401
|
+
if (options.base) args.push('--base', options.base)
|
|
402
|
+
if (options.web) args.push('--web', options.web)
|
|
403
|
+
if (options.bare) args.push('--bare', options.bare)
|
|
404
|
+
const run = spawnSync(process.execPath, args, {
|
|
405
|
+
encoding: 'utf8',
|
|
406
|
+
timeout: smokeTimeout + 5000,
|
|
407
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
408
|
+
env: sanitizedEnv(isolationEnv(context.isolation)),
|
|
409
|
+
})
|
|
410
|
+
const durationMs = Date.now() - startedAt
|
|
411
|
+
const meta = mode => ({ executed: true, modes: ['bundle-host'], durationMs, failure: null, ...mode })
|
|
412
|
+
if (run.error) {
|
|
413
|
+
const reason = `bundle smoke failed to start: ${run.error.message}`
|
|
414
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: meta({ failure: reason }) }
|
|
415
|
+
}
|
|
416
|
+
let smoke
|
|
417
|
+
try { smoke = JSON.parse(run.stdout) } catch {
|
|
418
|
+
const reason = `bundle smoke produced no JSON: ${(run.stderr || '').slice(0, 300)}`
|
|
419
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: meta({ failure: reason }) }
|
|
420
|
+
}
|
|
421
|
+
const state = smoke.g3?.state
|
|
422
|
+
if (smoke.g2?.compatibility === 'reject') {
|
|
423
|
+
const reason = 'G2 gate rejected: host would skip this bundle'
|
|
424
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason, details: [{ item: 'G2', result: 'FAIL', reason: JSON.stringify(smoke.g2) }] }, runtime: meta({ failure: reason }) }
|
|
425
|
+
}
|
|
426
|
+
if (state === 'verified' && smoke.g4?.ok === false) {
|
|
427
|
+
const reason = 'G4 failed: dsh.client has no package-root row or missing client export'
|
|
428
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason, details: [{ item: 'G4', result: 'FAIL', reason: JSON.stringify(smoke.g4) }] }, runtime: meta({}) }
|
|
429
|
+
}
|
|
430
|
+
if (state === 'verified') {
|
|
431
|
+
const reason = `G0-G3 verified; rows=${smoke.g3.active}/${smoke.g3.rows.length}${smoke.g4?.hasClient ? '; G4 static ok' : ''}`
|
|
432
|
+
return {
|
|
433
|
+
check: { ...base, result: 'PASS', coverage: 'full', reason, details: [{ item: 'G0', result: 'PASS', reason: 'patch parsed' }, { item: 'G1', result: 'PASS', reason: `rows ${smoke.g1.targetRows}` }, { item: 'G3', result: 'PASS', reason: `rows ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE` }] },
|
|
434
|
+
runtime: meta({}),
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (state === 'partial') {
|
|
438
|
+
const reason = `G3 partial: ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE; target profile does not provide all services`
|
|
439
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason, details: [{ item: 'G3', result: 'WARN', reason: JSON.stringify(smoke.g3.rows) }] }, runtime: meta({ failure: reason }) }
|
|
440
|
+
}
|
|
441
|
+
const reason = `bundle smoke failed: ${JSON.stringify(smoke.g3?.rows ?? [])}`
|
|
442
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason }, runtime: meta({ failure: reason }) }
|
|
443
|
+
}
|
|
444
|
+
// A bundle with a real server entry can still be exercised by L1-L3 with
|
|
445
|
+
// --treat-as-plugin. That is runtime evidence for the entry, but it is not
|
|
446
|
+
// a full G0-G3 boot; the reason says so instead of requiring --host forever.
|
|
447
|
+
if (runtimeKind === 'plugin' && runtimeUsable) {
|
|
448
|
+
return {
|
|
449
|
+
check: { ...base, result: 'PASS', coverage: 'full', reason: 'server-entry L1-L3 PASS; bundle G0-G3 not run (pass --host <app-boot/lib/index.js> for the full boot)' },
|
|
450
|
+
runtime: { executed: true, modes: [], durationMs: 0, failure: null },
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const reason = 'bundle runtime requires --host <app-boot/lib/index.js> or --treat-as-plugin --runtime <path>'
|
|
454
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: { ...noRuntime, failure: reason } }
|
|
236
455
|
}
|
|
237
456
|
if (c4?.result === 'PASS') {
|
|
238
|
-
return {
|
|
457
|
+
return {
|
|
458
|
+
check: { ...base, result: 'PASS', coverage: 'full', reason: kind === 'bundle' ? 'server-entry ACTIVE only; bundle G0-G4 not run' : '' },
|
|
459
|
+
runtime: { executed: true, modes: [], durationMs: 0, failure: null },
|
|
460
|
+
}
|
|
239
461
|
}
|
|
240
|
-
if (c4?.result === 'FAIL')
|
|
241
|
-
|
|
462
|
+
if (c4?.result === 'FAIL') {
|
|
463
|
+
const reason = c4.reason || 'runtime failed'
|
|
464
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason }, runtime: { executed: true, modes: [], durationMs: 0, failure: reason } }
|
|
465
|
+
}
|
|
466
|
+
const reason = c4?.reason || 'no runtime evidence'
|
|
467
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: { ...noRuntime, failure: reason } }
|
|
242
468
|
}
|
|
243
469
|
|
|
244
470
|
/**
|
|
@@ -334,6 +560,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
334
560
|
const host = detectHostRuntime()
|
|
335
561
|
const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
|
|
336
562
|
|
|
563
|
+
// `--runtime auto` / `--host auto` resolve from the installed dsh. A value
|
|
564
|
+
// that still cannot be resolved is a usage error: silently skipping the run
|
|
565
|
+
// would turn "asked for runtime" into a static report.
|
|
566
|
+
try {
|
|
567
|
+
if (options.runtime) {
|
|
568
|
+
const resolved = resolveRuntimeTarget(options.runtime, 'cordis')
|
|
569
|
+
if (!resolved) throw new Error("--runtime auto could not locate the installed dsh's cordis lib/index.js; pass an explicit path")
|
|
570
|
+
options.runtime = resolved
|
|
571
|
+
}
|
|
572
|
+
if (options.host) {
|
|
573
|
+
const resolved = resolveRuntimeTarget(options.host, 'host')
|
|
574
|
+
if (!resolved) throw new Error("--host auto could not locate the installed dsh's dsh-app-boot/lib/index.js; pass an explicit path")
|
|
575
|
+
options.host = resolved
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
process.stderr.write(`dsh-plugin-verify: ${error.message}\n`)
|
|
579
|
+
return 3
|
|
580
|
+
}
|
|
581
|
+
|
|
337
582
|
const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
|
|
338
583
|
runtimeVersion: runtimeInfo.dsh,
|
|
339
584
|
installSpec: options.installSpec,
|
|
@@ -359,11 +604,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
359
604
|
]
|
|
360
605
|
const declarationAdjusted = declarationChecks.map(item => (item.result === 'WARN' && ['DSC-11', 'DSC-13'].includes(item.id) ? { ...item, advisory: true } : item))
|
|
361
606
|
const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative, scan)]
|
|
607
|
+
const securityChecks = runSecurityChecks({ dir, manifest, sources, entry, kind })
|
|
362
608
|
|
|
363
609
|
if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
|
|
364
610
|
let runtimeKind = kind
|
|
365
611
|
let runtimeEntry = entry
|
|
366
|
-
|
|
612
|
+
// An explicit --runtime is already an opt-in to execute code. A bundle that
|
|
613
|
+
// carries a real server entry can therefore be exercised without repeating
|
|
614
|
+
// --treat-as-plugin; a patch-only bundle still has no entry and needs --host.
|
|
615
|
+
const runServerEntry = options.treatAsPlugin || (Boolean(options.runtime) && kind === 'bundle' && entry?.exists === true)
|
|
616
|
+
if (runServerEntry && manifest) {
|
|
367
617
|
runtimeKind = 'plugin'
|
|
368
618
|
if (!runtimeEntry?.exists) {
|
|
369
619
|
const candidates = ['lib/index.js', 'index.js', 'lib/public-entry.js', 'src/index.ts', 'src/index.js', 'src/index.mjs']
|
|
@@ -374,38 +624,107 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
374
624
|
}
|
|
375
625
|
}
|
|
376
626
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
627
|
+
const runtimeWillExecute = !options.noExec && Boolean(manifest) && Boolean(options.runtime) && runtimeKind === 'plugin' && runtimeEntry?.exists === true
|
|
628
|
+
const hostWillExecute = !options.noExec && Boolean(manifest) && Boolean(options.host) && kind === 'bundle'
|
|
629
|
+
const isolation = runtimeWillExecute || hostWillExecute ? createRuntimeIsolation() : null
|
|
630
|
+
|
|
631
|
+
let runtimeChecks = null
|
|
632
|
+
let runCheck = null
|
|
633
|
+
let runtimeRun = { checks: [], durationMs: null, error: null }
|
|
634
|
+
let entryRun = { executed: false, modes: [], durationMs: null, failure: null }
|
|
635
|
+
let runtimeSummary = null
|
|
636
|
+
try {
|
|
637
|
+
if (options.noExec || !manifest) {
|
|
638
|
+
const reason = options.noExec ? '--no-exec' : 'manifest unavailable'
|
|
639
|
+
runtimeChecks = ['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].map(id => ({
|
|
640
|
+
id, clause: { 'DSC-04': 'C-4', 'DSC-05': 'C-5', 'DSC-06': 'C-6', 'DSC-07': 'C-7', 'DSC-08': 'C-8', 'DSC-09': 'C-9' }[id],
|
|
641
|
+
layer: { 'DSC-04': 'L1', 'DSC-05': 'L1', 'DSC-06': 'L1+L2', 'DSC-07': 'L2', 'DSC-08': 'L3', 'DSC-09': 'L1' }[id],
|
|
642
|
+
result: 'ERROR', coverage: 'not-run', reason, details: [], evidence: [], method: 'M', advisory: false,
|
|
643
|
+
}))
|
|
644
|
+
} else if (!options.runtime) {
|
|
645
|
+
runtimeChecks = runtimeNotRun('no --runtime supplied; L1-L3 not run')
|
|
646
|
+
} else if (runtimeKind !== 'plugin') {
|
|
647
|
+
const reason = kind === 'bundle' && !entry?.exists
|
|
648
|
+
? 'kind=bundle has no server entry; L1-L3 not run (use --host <app-boot/lib/index.js> for G0-G3)'
|
|
649
|
+
: `kind=${runtimeKind}; L1-L3 apply to plugin entries only (pass --treat-as-plugin for a bundle's server entry)`
|
|
650
|
+
runtimeChecks = runtimeNotRun(reason)
|
|
651
|
+
} else if (!runtimeEntry?.exists) {
|
|
652
|
+
runtimeChecks = runtimeNotRun('no resolvable runtime entry; L1-L3 not run')
|
|
653
|
+
} else {
|
|
654
|
+
runtimeRun = await isolatedRuntimeChecks({
|
|
655
|
+
dir, manifest, kind: runtimeKind, entry: runtimeEntry,
|
|
656
|
+
runtimePath: options.runtime, timeoutMs: options.timeoutMs,
|
|
657
|
+
home: isolation?.home, tmp: isolation?.tmp, isolation,
|
|
658
|
+
})
|
|
659
|
+
runtimeChecks = runtimeRun.checks
|
|
660
|
+
}
|
|
661
|
+
const rawRuntimeChecks = runtimeChecks
|
|
662
|
+
const rawRuntimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
663
|
+
if (runtimeWillExecute && !rawRuntimeUsable && !runtimeRun.error) {
|
|
664
|
+
// The child may exit cleanly while every check is not-run (bad
|
|
665
|
+
// --runtime path, missing entry, blocked import). That is a failed run,
|
|
666
|
+
// not a quiet static result: surface its reason in runtimeChecks.
|
|
667
|
+
const reason = runtimeChecks.find(item => String(item.reason ?? '') !== '')?.reason || 'runtime produced no usable result'
|
|
668
|
+
runtimeRun = { ...runtimeRun, error: reason }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// A bundle/client has no plugin entry for L1-L3. Instead of leaving all six
|
|
672
|
+
// runtime checks NA, cover their statically decidable part explicitly and
|
|
673
|
+
// mark the coverage partial (the report says what still needs --host/--runtime).
|
|
674
|
+
const runtimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
675
|
+
if (manifest && ['bundle', 'client'].includes(kind) && !runtimeUsable) {
|
|
676
|
+
const staticChecks = runStaticBundleChecks({ manifest, kind, entry, patch, sources, g4, scan, dir })
|
|
677
|
+
const staticById = new Map(staticChecks.map(item => [item.id, item]))
|
|
678
|
+
runtimeChecks = runtimeChecks.map(item => staticById.get(item.id) ?? item)
|
|
679
|
+
}
|
|
391
680
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
681
|
+
const run = await entryRunCheck(kind, runtimeChecks, options, dir, { runtimeKind, runtimeUsable, isolation })
|
|
682
|
+
runCheck = run.check
|
|
683
|
+
entryRun = run.runtime
|
|
684
|
+
|
|
685
|
+
// Runtime provenance is additive: what actually ran, for how long, under
|
|
686
|
+
// which isolation, and why it failed. Static rows stay exactly as they are.
|
|
687
|
+
const modes = []
|
|
688
|
+
if (runtimeWillExecute) modes.push('cordis')
|
|
689
|
+
if (entryRun.modes.includes('bundle-host')) modes.push('bundle-host')
|
|
690
|
+
const failure = [runtimeRun.error, entryRun.executed ? entryRun.failure : null].filter(Boolean).join('; ') || null
|
|
691
|
+
const durationMs = (Number.isFinite(runtimeRun.durationMs) ? runtimeRun.durationMs : 0) + (Number.isFinite(entryRun.durationMs) ? entryRun.durationMs : 0)
|
|
692
|
+
let notRunReason = null
|
|
693
|
+
if (!runtimeWillExecute && !hostWillExecute) {
|
|
694
|
+
if (options.noExec) notRunReason = '--no-exec: nothing executed'
|
|
695
|
+
else if (options.runtime) notRunReason = (rawRuntimeChecks ?? []).find(item => String(item.reason ?? '') !== '')?.reason ?? '--runtime supplied but it was not executed'
|
|
696
|
+
else if (options.host) notRunReason = `--host supplied but kind=${kind} is not a bundle; G0-G3 not run`
|
|
697
|
+
else notRunReason = 'no --runtime/--host supplied; nothing executed'
|
|
698
|
+
}
|
|
699
|
+
const summary = {
|
|
700
|
+
schema: 'dsh-runtime-checks/1',
|
|
701
|
+
executed: runtimeWillExecute || entryRun.executed === true,
|
|
702
|
+
modes,
|
|
703
|
+
durationMs,
|
|
704
|
+
isolation: {
|
|
705
|
+
isolatedHome: Boolean(isolation),
|
|
706
|
+
env: 'allowlist',
|
|
707
|
+
timeoutMs: options.timeoutMs,
|
|
708
|
+
},
|
|
709
|
+
reason: notRunReason,
|
|
710
|
+
failure,
|
|
711
|
+
checks: (rawRuntimeChecks ?? []).map(item => ({
|
|
712
|
+
id: item.id, result: item.result, coverage: item.coverage, reason: item.reason ?? '',
|
|
713
|
+
})),
|
|
714
|
+
entry: runCheck ? { id: 'DSC-14', result: runCheck.result, coverage: runCheck.coverage, reason: runCheck.reason ?? '', durationMs: entryRun.durationMs ?? null } : null,
|
|
715
|
+
}
|
|
716
|
+
runtimeSummary = summary
|
|
717
|
+
} finally {
|
|
718
|
+
isolation?.cleanup()
|
|
400
719
|
}
|
|
401
720
|
|
|
402
|
-
const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
|
|
403
721
|
const checks = [
|
|
404
722
|
manifestChecks[0], manifestChecks[1], manifestChecks[2],
|
|
405
723
|
...runtimeChecks,
|
|
406
724
|
runCheck,
|
|
407
725
|
advisoryChecks[0],
|
|
408
726
|
...declarationAdjusted,
|
|
727
|
+
...(options.scope === 'security' ? securityChecks : []),
|
|
409
728
|
]
|
|
410
729
|
|
|
411
730
|
if (g4?.result === 'FAIL') {
|
|
@@ -440,7 +759,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
440
759
|
entryClaim: options.entryClaim,
|
|
441
760
|
advice: options.advice,
|
|
442
761
|
declarations: declarationsOf(manifest),
|
|
762
|
+
runtimeChecks: runtimeSummary,
|
|
763
|
+
securityChecks,
|
|
443
764
|
})
|
|
765
|
+
// An explicitly requested runtime/host run that could not produce a result
|
|
766
|
+
// is not a clean static pass: the caller asked for execution, so say the run
|
|
767
|
+
// is incomplete. A static report (no flag) is unaffected.
|
|
768
|
+
if ((runtimeWillExecute || hostWillExecute) && runtimeSummary?.failure && report.conclusion.status !== 'fail') {
|
|
769
|
+
report.conclusion.status = 'incomplete'
|
|
770
|
+
report.conclusion.exitCode = 2
|
|
771
|
+
report.conclusion.reason = [report.conclusion.reason, `runtime/host did not complete: ${runtimeSummary.failure}`].filter(Boolean).join('; ')
|
|
772
|
+
}
|
|
444
773
|
const output = writeReport(report, options.format)
|
|
445
774
|
if (options.out) {
|
|
446
775
|
const outPath = path.resolve(options.out)
|