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.
Files changed (69) hide show
  1. package/40-checker/fixtures/security/sec15-clean/index.mjs +3 -0
  2. package/40-checker/fixtures/security/sec15-clean/package.json +19 -0
  3. package/40-checker/fixtures/security/sec15-missing-ref/index.mjs +3 -0
  4. package/40-checker/fixtures/security/sec15-missing-ref/package.json +22 -0
  5. package/40-checker/fixtures/security/sec15-node/index.mjs +3 -0
  6. package/40-checker/fixtures/security/sec15-node/package.json +22 -0
  7. package/40-checker/fixtures/security/sec15-node/scripts/build.mjs +1 -0
  8. package/40-checker/fixtures/security/sec15-pipe/index.mjs +3 -0
  9. package/40-checker/fixtures/security/sec15-pipe/package.json +22 -0
  10. package/40-checker/fixtures/security/sec16-client/index.mjs +3 -0
  11. package/40-checker/fixtures/security/sec16-client/lib/client.js +3 -0
  12. package/40-checker/fixtures/security/sec16-client/package.json +21 -0
  13. package/40-checker/fixtures/security/sec16-declared/index.mjs +3 -0
  14. package/40-checker/fixtures/security/sec16-declared/package.json +19 -0
  15. package/40-checker/fixtures/security/sec16-eval/index.mjs +3 -0
  16. package/40-checker/fixtures/security/sec16-eval/package.json +19 -0
  17. package/40-checker/fixtures/security/sec16-require/index.mjs +4 -0
  18. package/40-checker/fixtures/security/sec16-require/package.json +19 -0
  19. package/40-checker/fixtures/security/sec17-env/index.mjs +5 -0
  20. package/40-checker/fixtures/security/sec17-env/package.json +19 -0
  21. package/40-checker/fixtures/security/sec17-seam/index.mjs +3 -0
  22. package/40-checker/fixtures/security/sec17-seam/package.json +19 -0
  23. package/40-checker/fixtures/security/sec17-ssh/index.mjs +6 -0
  24. package/40-checker/fixtures/security/sec17-ssh/package.json +19 -0
  25. package/40-checker/fixtures/security/sec18-bad/index.mjs +3 -0
  26. package/40-checker/fixtures/security/sec18-bad/package.json +21 -0
  27. package/40-checker/fixtures/security/sec18-good/index.mjs +3 -0
  28. package/40-checker/fixtures/security/sec18-good/package.json +26 -0
  29. package/40-checker/fixtures/security/sec18-undeclared/index.mjs +3 -0
  30. package/40-checker/fixtures/security/sec18-undeclared/package.json +26 -0
  31. package/40-checker/fixtures/security/sec19-blob/index.mjs +2 -0
  32. package/40-checker/fixtures/security/sec19-blob/package.json +19 -0
  33. package/40-checker/fixtures/security/sec19-clean/index.mjs +2 -0
  34. package/40-checker/fixtures/security/sec19-clean/package.json +19 -0
  35. package/40-checker/fixtures/security/sec19-wasm/blob.wasm +0 -0
  36. package/40-checker/fixtures/security/sec19-wasm/index.mjs +3 -0
  37. package/40-checker/fixtures/security/sec19-wasm/package.json +19 -0
  38. package/40-checker/fixtures/security/sec20-unwrapped/index.mjs +4 -0
  39. package/40-checker/fixtures/security/sec20-unwrapped/package.json +23 -0
  40. package/40-checker/fixtures/security/sec20-wrapped/index.mjs +3 -0
  41. package/40-checker/fixtures/security/sec20-wrapped/package.json +23 -0
  42. package/40-checker/fixtures/security/sec21-known/index.mjs +3 -0
  43. package/40-checker/fixtures/security/sec21-known/package.json +23 -0
  44. package/40-checker/fixtures/security/sec21-typosquat/index.mjs +3 -0
  45. package/40-checker/fixtures/security/sec21-typosquat/package.json +22 -0
  46. package/40-checker/fixtures/security/sec22-attested/index.mjs +3 -0
  47. package/40-checker/fixtures/security/sec22-attested/package.json +22 -0
  48. package/40-checker/fixtures/security/sec22-none/index.mjs +3 -0
  49. package/40-checker/fixtures/security/sec22-none/package.json +19 -0
  50. package/40-checker/scripts/sign-evidence.mjs +169 -0
  51. package/40-checker/src/cli.mjs +402 -73
  52. package/40-checker/src/declarations.mjs +10 -10
  53. package/40-checker/src/flow-evidence.mjs +21 -0
  54. package/40-checker/src/index.mjs +2 -1
  55. package/40-checker/src/report.mjs +116 -4
  56. package/40-checker/src/runtime-child.mjs +68 -0
  57. package/40-checker/src/runtime.mjs +3 -1
  58. package/40-checker/src/sandbox-run.mjs +406 -35
  59. package/40-checker/src/security.mjs +1065 -0
  60. package/40-checker/src/signature.mjs +249 -0
  61. package/40-checker/src/util.mjs +53 -0
  62. package/CHANGELOG.md +34 -0
  63. package/docs/SIGNING.md +137 -0
  64. package/docs/coverage.md +39 -6
  65. package/package.json +1 -1
  66. package/spec/coverage-contract.md +33 -11
  67. package/spec/dsc-clauses.json +410 -9
  68. package/spec/remediation.json +196 -0
  69. package/spec/schemas/conformance.schema.json +80 -2
@@ -86,7 +86,7 @@ const C10_SUBPROCESS_METHODS = new Set(['execSync', 'execFileSync', 'spawnSync',
86
86
 
87
87
  const PARSE_CACHE = new WeakMap()
88
88
 
89
- function parseSource(source) {
89
+ export function parseSource(source) {
90
90
  const cached = PARSE_CACHE.get(source)
91
91
  if (cached) return cached
92
92
  const record = { ast: null, error: null }
@@ -107,21 +107,21 @@ function parseSource(source) {
107
107
  return record
108
108
  }
109
109
 
110
- function normalizeRelative(relative) {
110
+ export function normalizeRelative(relative) {
111
111
  return String(relative ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
112
112
  }
113
113
 
114
114
  // Declaration files have no runtime behavior; parsing them for import-time
115
115
  // side effects or execution strings only creates noise.
116
- function isDeclarationFile(relative) {
116
+ export function isDeclarationFile(relative) {
117
117
  return /\.d\.[cm]?ts$/i.test(String(relative ?? ''))
118
118
  }
119
119
 
120
- function identifierName(node) {
120
+ export function identifierName(node) {
121
121
  return node?.type === 'Identifier' ? node.name : null
122
122
  }
123
123
 
124
- function memberPropertyName(node) {
124
+ export function memberPropertyName(node) {
125
125
  if (!node || node.type !== 'MemberExpression') return null
126
126
  if (!node.computed && node.property?.type === 'Identifier') return node.property.name
127
127
  if (node.property?.type === 'Literal' && typeof node.property.value === 'string') return node.property.value
@@ -141,7 +141,7 @@ function calledFunctionName(call) {
141
141
  return null
142
142
  }
143
143
 
144
- function walkAst(root, visit) {
144
+ export function walkAst(root, visit) {
145
145
  const ancestors = []
146
146
  const walkNode = (node) => {
147
147
  if (!node || typeof node.type !== 'string') return
@@ -163,7 +163,7 @@ function walkAst(root, visit) {
163
163
 
164
164
  // Module scope means "runs while the module is evaluated". Function bodies and
165
165
  // class method bodies are skipped; top-level if/try/loop bodies are not.
166
- function walkModuleScope(ast, visit) {
166
+ export function walkModuleScope(ast, visit) {
167
167
  const ancestors = []
168
168
  const walkNode = (node) => {
169
169
  if (!node || typeof node.type !== 'string') return
@@ -295,7 +295,7 @@ function classifyLiteral(node, ancestors) {
295
295
  return 'unknown'
296
296
  }
297
297
 
298
- function literalStringValue(node) {
298
+ export function literalStringValue(node) {
299
299
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value
300
300
  if (node.type === 'TemplateElement' && node.value) {
301
301
  if (typeof node.value.cooked === 'string') return node.value.cooked
@@ -341,7 +341,7 @@ function literalCandidates(base) {
341
341
  return candidates
342
342
  }
343
343
 
344
- function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
344
+ export function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
345
345
  const spec = String(specifier).replace(/\\/g, '/')
346
346
  if (!(spec.startsWith('./') || spec.startsWith('../') || spec === '.' || spec === '..')) return { kind: 'package' }
347
347
  const base = path.posix.normalize(path.posix.join(path.posix.dirname(normalizeRelative(fromRelative)), spec))
@@ -352,7 +352,7 @@ function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
352
352
  return { kind: 'unresolved' }
353
353
  }
354
354
 
355
- function collectModuleEdges(ast) {
355
+ export function collectModuleEdges(ast) {
356
356
  const edges = []
357
357
  walkAst(ast, (node) => {
358
358
  if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') {
@@ -28,6 +28,15 @@ import fs from 'node:fs'
28
28
  import os from 'node:os'
29
29
  import path from 'node:path'
30
30
  import { spawnSync } from 'node:child_process'
31
+ import { signEvidence } from './signature.mjs'
32
+
33
+ // Signing helpers live in signature.mjs; re-exported here so callers that
34
+ // already import the evidence model do not need a second module path.
35
+ export {
36
+ SIGNATURE_ALG, SIGNATURE_SCHEMA, canonicalJson as signatureCanonicalJson,
37
+ evidenceSigningPayload, generateEd25519KeyPair, keyIdFromPublicKey,
38
+ signEvidence, verifyEvidenceSignature,
39
+ } from './signature.mjs'
31
40
 
32
41
  export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
33
42
  export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
@@ -107,6 +116,18 @@ export function evidenceHashMatches(document) {
107
116
  return declared === evidenceHash(document)
108
117
  }
109
118
 
119
+ /**
120
+ * Attach an Ed25519 signature to a flow-evidence document. The signature
121
+ * covers canonical JSON minus `signature` (the body plus `chain`), so it binds
122
+ * the declared chain hash while never changing `chain.evidenceHash`; it never
123
+ * upgrades a claim level. Verification is `verifyEvidenceSignature()`
124
+ * (re-exported from signature.mjs); structural acceptance remains
125
+ * `validateFlowEvidence()`.
126
+ */
127
+ export function attachEvidenceSignature(document, privateKey, options = {}) {
128
+ return signEvidence(document, privateKey, options)
129
+ }
130
+
110
131
  const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
111
132
  const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
112
133
 
@@ -5,7 +5,8 @@ export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdviso
5
5
  export { readSources, readSourcesWithStats, listSourceFiles, scanSourceFiles } from './util.mjs'
6
6
  export { runRuntimeChecks } from './runtime.mjs'
7
7
  export { runStaticBundleChecks } from './bundle-static.mjs'
8
- export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, DECLARATION_CHECKS } from './report.mjs'
8
+ export { runSecurityChecks, checkLifecycleScripts, checkDynamicExecution, checkSensitiveFileReads, checkEgressEndpoints, checkOpaqueArtifacts, checkPromptInjectionClues, checkSupplyChain, checkProvenance, SECURITY_IDS } from './security.mjs'
9
+ export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, buildSecurityView, DECLARATION_CHECKS, SECURITY_CHECKS, SECURITY_MANDATORY } from './report.mjs'
9
10
  export { joinAdvice, DOCS_BASE } from './remediation.mjs'
10
11
  export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
11
12
  export { parseInstallSpec } from './install-spec.mjs'
@@ -13,8 +13,13 @@ export const ENTRY_RUN = ['DSC-01', 'DSC-02', 'DSC-03', 'DSC-11', 'DSC-12', 'DSC
13
13
  export const ENTRY_BLOCKING = ['C-1.1', 'C-1.2', 'C-1.4', 'C-3.1', 'C-3.2', 'C-3.5']
14
14
  export const ENTRY_RUNTIME_BLOCKING = [...ENTRY_BLOCKING, 'C-2.2', 'C-2.3']
15
15
 
16
+ /** The four security checks that block in `--scope security`; the rest are advisory. */
17
+ export const SECURITY_MANDATORY = ['DSC-15', 'DSC-16', 'DSC-17', 'DSC-21']
18
+ export const SECURITY_CHECKS = new Set(['DSC-15', 'DSC-16', 'DSC-17', 'DSC-18', 'DSC-19', 'DSC-20', 'DSC-21', 'DSC-22'])
19
+
16
20
  function mandatoryFor(scope, profile, staticOnly, entryClaim) {
17
21
  if (scope === 'mvp') return MVP_CHECKS
22
+ if (scope === 'security') return SECURITY_MANDATORY
18
23
  if (scope === 'entry') return ENTRY_RUN.filter(id => (entryClaim !== 'static' || id !== 'DSC-14') && (id !== 'DSC-03' || profile === 'listing' || profile === 'both'))
19
24
  if (staticOnly) return STATIC_MANDATORY
20
25
  return profile === 'listing' || profile === 'both' ? LISTING_MANDATORY : PACKAGE_MANDATORY
@@ -68,6 +73,11 @@ export function howToEnableFor(item) {
68
73
  if (id === 'DSC-14') {
69
74
  return 'pass --host <app-boot/lib/index.js> for bundle G0-G3, or --runtime <path> for a plugin; omit --no-exec and --entry-claim static'
70
75
  }
76
+ if (id === 'DSC-15') return 'fix package.json#scripts so install lifecycle commands are auditable; --scope security makes DSC-15 blocking'
77
+ if (['DSC-16', 'DSC-17', 'DSC-21'].includes(id)) return 'provide a readable entry/source tree and rerun; --scope security makes this check blocking'
78
+ if (id === 'DSC-19') return 'provide a readable package tree; binaries are recorded by sha256, never executed'
79
+ if (id === 'DSC-22') return 'supply registry metadata or an attestation/signature field; without input this check is not-run'
80
+ if (['DSC-18', 'DSC-20'].includes(id)) return 'this check is advisory; it never blocks and needs no extra input'
71
81
  if (['DSC-11', 'DSC-12', 'DSC-13'].includes(id)) {
72
82
  if (/manifest unavailable/.test(reason)) return 'fix package.json; these declaration checks cannot run without a manifest'
73
83
  return 'run the full profile without --no-exec; declaration checks need source files'
@@ -108,8 +118,58 @@ function allowedNa(check, scope, profile, kind) {
108
118
  return false
109
119
  }
110
120
 
111
- export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null }) {
121
+ function capSecurityCheck(item) {
122
+ if (item.result !== 'FAIL') return item
123
+ return {
124
+ ...item,
125
+ result: 'WARN',
126
+ reason: `capped WARN (non-security scope): ${item.reason ?? ''}`.trim(),
127
+ details: (item.details ?? []).map(detailRow => (
128
+ detailRow.result === 'FAIL'
129
+ ? { ...detailRow, result: 'WARN', reason: `capped WARN (non-security scope): ${detailRow.reason ?? ''}`.trim() }
130
+ : detailRow
131
+ )),
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The additive `security` block. In `--scope security` the eight checks keep
137
+ * their real verdict (and four of them are mandatory); in every other scope a
138
+ * FAIL is capped to WARN so the default verdict cannot change.
139
+ */
140
+ export function buildSecurityView(securityChecks, scope) {
141
+ const input = Array.isArray(securityChecks) ? securityChecks.filter(item => SECURITY_CHECKS.has(item.id)) : []
142
+ const checks = scope === 'security' ? input : input.map(capSecurityCheck)
143
+ // Only DSC-15/16/17 may block; DSC-21 is mandatory but its findings are
144
+ // capped at WARN, and DSC-18/19/20/22 are advisory by definition.
145
+ const blocking = checks
146
+ .filter(item => item.result === 'FAIL' && ['DSC-15', 'DSC-16', 'DSC-17'].includes(item.id))
147
+ .map(item => item.id)
148
+ const warn = checks.filter(item => item.result === 'WARN' && item.advisory !== true).map(item => item.id)
149
+ const advisory = checks.filter(item => item.result === 'WARN' && item.advisory === true).map(item => item.id)
150
+ const notRun = checks
151
+ .filter(item => item.result === 'NA' || item.result === 'ERROR' || item.coverage === 'not-run')
152
+ .map(item => item.id)
153
+ const mandatoryNotRun = SECURITY_MANDATORY.some(id => !checks.some(item => item.id === id && ['PASS', 'WARN', 'FAIL'].includes(item.result)))
154
+ let status
155
+ if (blocking.length > 0) status = 'fail'
156
+ else if (scope === 'security' && mandatoryNotRun) status = 'incomplete'
157
+ else if (warn.length > 0 || advisory.length > 0 || mandatoryNotRun) status = 'warn'
158
+ else status = 'pass'
159
+ return {
160
+ scope: scope === 'security' ? 'security' : 'advisory',
161
+ status,
162
+ checks,
163
+ blocking,
164
+ warn,
165
+ advisory,
166
+ notRun,
167
+ }
168
+ }
169
+
170
+ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null, runtimeChecks = null, securityChecks = null }) {
112
171
  const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
172
+ const security = buildSecurityView(securityChecks, scope)
113
173
  const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
114
174
  const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
115
175
  const effectiveChecks = staticOnly
@@ -123,7 +183,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
123
183
  const missing = mandatory.filter(id => !byId.has(id))
124
184
  const failed = selected.filter(item => item.result === 'FAIL')
125
185
  const errored = selected.filter(item => item.result === 'ERROR')
126
- const notRun = selected.filter(item => !allowedNa(item, scope, profile, kind) && (item.coverage === 'not-run' || item.coverage === 'partial'))
186
+ // A static `partial` PASS is still a PASS for the mandatory set: it is
187
+ // recorded in buckets.partial / coverage.partial, and "what was not run" is
188
+ // its own signal. Only a check that did not run at all makes the report
189
+ // incomplete. Otherwise every bundle would be exit 2 forever, because the
190
+ // static subset can never be full without executing the package.
191
+ const notRun = selected.filter(item => !allowedNa(item, scope, profile, kind) && item.coverage === 'not-run')
127
192
  const notRunIds = [...new Set([...missing, ...errored.map(item => item.id), ...notRun.map(item => item.id)])]
128
193
  const warnings = selected.filter(item => item.result === 'WARN')
129
194
  const pass = selected.filter(item => item.result === 'PASS')
@@ -134,7 +199,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
134
199
  const evaluatedAll = effectiveChecks.filter(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
135
200
  const naAll = effectiveChecks.filter(item => item.result === 'NA')
136
201
  const errorAll = effectiveChecks.filter(item => item.result === 'ERROR')
137
- const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && (item.coverage === 'not-run' || item.coverage === 'partial'))
202
+ const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && item.coverage === 'not-run')
138
203
  const notRunAllIds = [...new Set([...notRunAll.map(item => item.id), ...errorAll.map(item => item.id), ...missing])]
139
204
  const entryBlockingIds = scope === 'entry' ? (entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING) : null
140
205
  const entryBlocking = entryBlockingIds ? summarizeBlocking(effectiveChecks, entryBlockingIds) : null
@@ -156,7 +221,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
156
221
  } else if (status === 'pass' || status === 'warn') {
157
222
  level = `DSH-ENTRY static${status === 'warn' ? ' (warnings)' : ''}`
158
223
  }
159
- } else if (scope !== 'mvp' && (status === 'pass' || status === 'warn')) {
224
+ } else if (scope !== 'mvp' && scope !== 'security' && (status === 'pass' || status === 'warn')) {
160
225
  const suffix = status === 'warn' ? ', warnings' : ''
161
226
  level = staticOnly ? `DS-C static${status === 'warn' ? ' (warnings)' : ''}` : profile === 'listing' || profile === 'both' ? `DS-C (listing${suffix})` : `DS-C (package${suffix})`
162
227
  }
@@ -176,6 +241,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
176
241
  const hygieneBucket = effectiveChecks.filter(item => item.result === 'WARN').map(item => bucketRow(item, {
177
242
  declaration: DECLARATION_CHECKS.has(item.id),
178
243
  advisory: item.advisory === true,
244
+ ...(SECURITY_CHECKS.has(item.id) ? { security: true } : {}),
179
245
  }))
180
246
  const notCheckedBucket = effectiveChecks
181
247
  .filter(item => item.result === 'NA' || item.result === 'ERROR')
@@ -235,6 +301,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
235
301
  entryBlocking,
236
302
  },
237
303
  buckets,
304
+ security,
238
305
  notChecked,
239
306
  metrics,
240
307
  conclusion: {
@@ -251,6 +318,11 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
251
318
  reason: reasonParts.join('; '),
252
319
  },
253
320
  }
321
+ // Additive only: describes what actually executed (runtime/host), the
322
+ // elapsed time, and the isolation it used. It is a sibling of `checks` and
323
+ // never changes the verdict. Kept out of the 1.0.0 schema shape and only
324
+ // emitted when a runtime/host run was requested.
325
+ if (runtimeChecks) report.runtimeChecks = runtimeChecks
254
326
  // Additive only: `advice` is a sibling of `checks`, never part of a check,
255
327
  // and the verdict is computed above without reading it.
256
328
  if (advice !== false) {
@@ -302,6 +374,41 @@ function renderBucket(lines, title, rows, byId) {
302
374
  }
303
375
  }
304
376
 
377
+ /**
378
+ * Runtime provenance: what actually ran, for how long, and where. A failed
379
+ * run must say why instead of silently falling back to static rows.
380
+ */
381
+ function renderRuntime(lines, runtime) {
382
+ if (!runtime) return
383
+ if (!runtime.executed) {
384
+ // Do not repeat six not-run rows that the partial/not-checked buckets
385
+ // already explain; one line is enough to say nothing executed.
386
+ lines.push(`Runtime checks (not executed): ${runtime.reason ?? runtime.failure ?? 'no runtime requested'}`)
387
+ lines.push('')
388
+ return
389
+ }
390
+ const modes = Array.isArray(runtime.modes) && runtime.modes.length > 0 ? runtime.modes.join('+') : 'none'
391
+ const elapsed = Number.isFinite(runtime.durationMs) ? `${runtime.durationMs} ms` : 'n/a'
392
+ const isolation = runtime.isolation?.isolatedHome ? 'temporary DSH_HOME' : 'un-isolated'
393
+ lines.push(`Runtime checks (${modes}, ${elapsed}, ${isolation}):`)
394
+ if (runtime.failure) lines.push(` failure: ${String(runtime.failure).replace(/\s+/g, ' ').slice(0, 200)}`)
395
+ const rows = Array.isArray(runtime.checks) ? runtime.checks : []
396
+ if (rows.length === 0) {
397
+ lines.push(' none')
398
+ } else {
399
+ for (const row of rows) {
400
+ const reason = String(row.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)
401
+ lines.push(` ${(ICON[row.result] ?? row.result).padEnd(4)} ${String(row.id).padEnd(7)} ${String(row.coverage ?? '-').padEnd(8)} ${reason}`)
402
+ }
403
+ }
404
+ const entry = runtime.entry
405
+ if (entry) {
406
+ const reason = String(entry.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)
407
+ lines.push(` ${(ICON[entry.result] ?? entry.result).padEnd(4)} ${String(entry.id).padEnd(7)} ${String(entry.coverage ?? '-').padEnd(8)} ${reason}`)
408
+ }
409
+ lines.push('')
410
+ }
411
+
305
412
  export function formatText(report) {
306
413
  const lines = []
307
414
  const { subject, anchor = {}, conclusion, coverage } = report
@@ -318,11 +425,16 @@ export function formatText(report) {
318
425
  lines.push('')
319
426
  renderBucket(lines, 'Partly checked (static subset)', report.buckets?.partial ?? [], byId)
320
427
  lines.push('')
428
+ renderRuntime(lines, report.runtimeChecks)
321
429
  renderBucket(lines, 'Not checked (NA / not-run / ERROR)', report.buckets?.notChecked ?? [], byId)
322
430
  lines.push('')
323
431
  const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
324
432
  lines.push(`passed (${passed.length}): ${passed.map(item => item.id).join(', ') || 'none'}`)
325
433
  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} partial=${coverage.partial ?? 0} error=${coverage.error} advisory=${coverage.advisory}`)
434
+ if (report.security) {
435
+ const security = report.security
436
+ lines.push(`security: scope=${security.scope} status=${security.status} blocking=[${(security.blocking ?? []).join(', ')}] warn=[${(security.warn ?? []).join(', ')}] advisory=[${(security.advisory ?? []).join(', ')}] notRun=[${(security.notRun ?? []).join(', ')}]`)
437
+ }
326
438
  if (coverage.entryBlocking) {
327
439
  const blocking = coverage.entryBlocking
328
440
  lines.push(`entryBlocking: checked=${blocking.checked?.length ?? '?'}/${blocking.required} failed=[${(blocking.failed ?? []).join(', ')}] notChecked=[${(blocking.notChecked ?? []).join(', ')}] pass=${blocking.pass}`)
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // Runs L1-L3 in a disposable child process so a plugin cannot block or take over
3
+ // the checker. Input: JSON on stdin. Output: one marker line + JSON on fd 3.
4
+ //
5
+ // The child is *expected to be hostile*: a plugin may leave timers, sockets or
6
+ // workers referenced after `dispose()`. Waiting for the process to exit then
7
+ // looks like a hang even though every check finished, so the result is written
8
+ // first and the process is terminated in the write callback. The parent also
9
+ // resolves on the marker line, so a plugin that keeps the event loop alive
10
+ // cannot turn a completed run into "killed after timeout".
11
+ import fs from 'node:fs'
12
+ import { runRuntimeChecks } from './runtime.mjs'
13
+ import { readSources } from './util.mjs'
14
+ import { scrubProcessEnv } from './sandbox-env.mjs'
15
+
16
+ export const MARKER = '__DSH_RUNTIME_CHECKS__'
17
+
18
+ const chunks = []
19
+ for await (const chunk of process.stdin) chunks.push(chunk)
20
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
21
+
22
+ // This process imports the inspected package; keep only allowlisted env keys,
23
+ // then apply the caller-supplied disposable HOME/DSH_HOME/TMPDIR. DSH_HOME is
24
+ // deliberately not allowlisted: an inherited real profile must never reach a
25
+ // plugin, while an explicit temp directory from the checker is trusted.
26
+ scrubProcessEnv()
27
+ const overrides = {
28
+ DSH_HOME: input.home,
29
+ HOME: input.home,
30
+ USERPROFILE: input.home,
31
+ TMPDIR: input.tmp,
32
+ TEMP: input.tmp,
33
+ TMP: input.tmp,
34
+ XDG_CONFIG_HOME: input.home ? `${input.home}/.config` : undefined,
35
+ XDG_DATA_HOME: input.home ? `${input.home}/.local/share` : undefined,
36
+ XDG_CACHE_HOME: input.home ? `${input.home}/.cache` : undefined,
37
+ }
38
+ for (const [key, value] of Object.entries(overrides)) {
39
+ if (typeof value === 'string' && value !== '') process.env[key] = value
40
+ }
41
+
42
+ const started = process.hrtime.bigint()
43
+ let payload
44
+ try {
45
+ let sources = []
46
+ try { sources = readSources(input.dir) } catch { sources = [] }
47
+ const checks = await runRuntimeChecks({ ...input, sources })
48
+ payload = { checks, durationMs: Number((process.hrtime.bigint() - started) / 1_000_000n), error: null }
49
+ } catch (error) {
50
+ payload = {
51
+ checks: [],
52
+ durationMs: Number((process.hrtime.bigint() - started) / 1_000_000n),
53
+ error: String(error?.stack ?? error?.message ?? error).slice(0, 2000),
54
+ }
55
+ }
56
+
57
+ const line = `${MARKER}${JSON.stringify(payload)}\n`
58
+ // The plugin shares stdout, so results go to a dedicated pipe (fd 3) that a
59
+ // plugin printing logs cannot interleave with or truncate.
60
+ const writeResult = (callback) => {
61
+ try { fs.writeSync(3, line) } catch {
62
+ // A direct `node src/runtime-child.mjs` has no fd 3: fall back to stdout.
63
+ process.stdout.write(line, callback)
64
+ return
65
+ }
66
+ callback()
67
+ }
68
+ writeResult(() => process.exit(0))
@@ -193,7 +193,9 @@ export async function runRuntimeChecks(input) {
193
193
  const provideResult = declared.length === 0 ? 'NA' : worst(provideDetails.map(item => item.result))
194
194
  const provideCheck = check({
195
195
  id: 'DSC-05', clause: 'C-5', layer: 'L1', result: provideResult,
196
- reason: provideDetails.filter(item => item.result !== 'PASS' && item.result !== 'NA').map(item => `${item.item}: ${item.reason}`).join(' | '),
196
+ // NA is a real finding too ("nothing declared"), so keep its reason; an
197
+ // empty reason here used to make DSC-05 look unexplained in the report.
198
+ reason: provideDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
197
199
  details: provideDetails,
198
200
  })
199
201