dsh-standards 1.3.0 → 1.4.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/scripts/build-sidecar.mjs +238 -8
- package/40-checker/src/author-cli.mjs +274 -7
- package/40-checker/src/author-report.mjs +14 -3
- package/40-checker/src/bundle-static.mjs +181 -0
- package/40-checker/src/cli.mjs +27 -3
- package/40-checker/src/client-shape.mjs +33 -3
- package/40-checker/src/declarations.mjs +53 -8
- package/40-checker/src/index.mjs +3 -1
- package/40-checker/src/manifest.mjs +87 -14
- package/40-checker/src/remediation.mjs +108 -7
- package/40-checker/src/report.mjs +83 -7
- package/40-checker/src/semver.mjs +112 -65
- package/40-checker/src/util.mjs +51 -8
- package/CHANGELOG.md +23 -0
- package/README.md +28 -16
- package/docs/coverage.md +82 -0
- package/package.json +1 -1
- package/spec/coverage-contract.md +100 -0
- package/spec/dsc-clauses.json +766 -0
- package/spec/examples/boost-card.example.json +15 -0
- package/spec/examples/flow-evidence.example.json +113 -0
- package/spec/remediation.json +54 -54
- package/spec/schemas/boost-card.schema.json +107 -0
- package/spec/schemas/conformance.schema.json +848 -0
|
@@ -10,13 +10,25 @@
|
|
|
10
10
|
* lifecycle read an existing sidecar (`--prev`) and only recompute
|
|
11
11
|
* `lifecycle` from `--today` and `publish/withdrawals.json`.
|
|
12
12
|
*
|
|
13
|
+
* Entry fields are a superset of the original `dsh-market-sidecar/1` shape
|
|
14
|
+
* (subject/levels/evidence/validity/lifecycle/selfAssessed are unchanged):
|
|
15
|
+
* title/repo/license from the published package.json, null when absent
|
|
16
|
+
* scope {dsh, cordis, platform} from dsh.engines / npm engines
|
|
17
|
+
* / peerDependencies, falling back to the audit anchor
|
|
18
|
+
* checks checker results as {id,result,coverage} only; detailed
|
|
19
|
+
* reasons stay in the report, never in the sidecar
|
|
20
|
+
* evidence url/sha256 kept for compatibility; reportUrl/
|
|
21
|
+
* reportSha256 are the same values under the new names
|
|
22
|
+
* checkedBy {checker, command} that produced the audit
|
|
23
|
+
* notChecked short honest list of what the run did not exercise
|
|
24
|
+
*
|
|
13
25
|
* Deliberately absent: advice (author view), issuer / recordHash / verifyUrl
|
|
14
|
-
* (we are not an issuer). `--today` pins the
|
|
15
|
-
* reproducible.
|
|
26
|
+
* (we are not an issuer), score / rank (we do not grade). `--today` pins the
|
|
27
|
+
* clock so CI and tests are reproducible.
|
|
16
28
|
*
|
|
17
29
|
* Usage:
|
|
18
30
|
* node 40-checker/scripts/build-sidecar.mjs --levels audit.json \
|
|
19
|
-
* --
|
|
31
|
+
* --report report.json --tarball dsh-security-pack-3.0.1.tgz \
|
|
20
32
|
* --out publish/sidecar.json --today 2026-11-06
|
|
21
33
|
*
|
|
22
34
|
* node 40-checker/scripts/build-sidecar.mjs --prev publish/sidecar.json \
|
|
@@ -36,8 +48,12 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
|
36
48
|
const HEX40 = /^[0-9a-f]{40}$/
|
|
37
49
|
const HEX64 = /^[0-9a-f]{64}$/
|
|
38
50
|
const LIFECYCLE = new Set(['active', 'stale', 'under-review', 'withdrawn'])
|
|
39
|
-
const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl'])
|
|
51
|
+
const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl', 'score', 'rank'])
|
|
40
52
|
const NOT_CLAIMED = ['official', 'certification', 'security-audit', 'endorsement', 'ordering', 'comparison', 'warranty']
|
|
53
|
+
const CHECK_KEYS = new Set(['id', 'result', 'coverage'])
|
|
54
|
+
const SCOPE_KEYS = new Set(['dsh', 'cordis', 'platform'])
|
|
55
|
+
const CHECKED_BY_KEYS = new Set(['checker', 'command'])
|
|
56
|
+
const REPO_GIT_SHORTHAND = /^github:([^/]+\/[^/]+?)(?:\.git)?$/
|
|
41
57
|
|
|
42
58
|
function usage() {
|
|
43
59
|
console.log(`build-sidecar — publish/sidecar.json (${SCHEMA})
|
|
@@ -58,9 +74,12 @@ Options:
|
|
|
58
74
|
--package <dir> package.json directory for name/version
|
|
59
75
|
--tarball <file> package tarball; sha256 is recorded in subject
|
|
60
76
|
--tarball-sha256 <hex> explicit tarball sha256 (checked against --tarball)
|
|
61
|
-
--
|
|
62
|
-
|
|
63
|
-
--
|
|
77
|
+
--report <file> dsh-conformance/1 checker report; its checks are
|
|
78
|
+
reduced to {id,result,coverage} (no details)
|
|
79
|
+
--checked-command <s> command recorded in checkedBy.command
|
|
80
|
+
--evidence-url <url> optional https evidence URL (url + reportUrl)
|
|
81
|
+
--evidence-file <file> hash this file into evidence.sha256/reportSha256
|
|
82
|
+
--evidence-sha256 <hex> explicit evidence sha256 (sha256 + reportSha256)
|
|
64
83
|
--checker-version <v> default dsh-standards package.json version
|
|
65
84
|
--checker-sha <40hex> default git HEAD when available
|
|
66
85
|
--index-url <url> default https://awesome-dsh-plugin.com/index.json
|
|
@@ -110,6 +129,7 @@ function parseArgs(argv) {
|
|
|
110
129
|
today: null, todayProvided: false, ttlDays: 90,
|
|
111
130
|
name: null, version: null, packageDir: null,
|
|
112
131
|
tarball: null, tarballSha256: null,
|
|
132
|
+
report: null, checkedCommand: null,
|
|
113
133
|
evidenceUrl: null, evidenceFile: null, evidenceSha256: null,
|
|
114
134
|
checkerVersion: null, checkerSha: null,
|
|
115
135
|
indexUrl: 'https://awesome-dsh-plugin.com/index.json',
|
|
@@ -134,6 +154,8 @@ function parseArgs(argv) {
|
|
|
134
154
|
else if (arg === '--package') options.packageDir = next()
|
|
135
155
|
else if (arg === '--tarball') options.tarball = next()
|
|
136
156
|
else if (arg === '--tarball-sha256') options.tarballSha256 = next().toLowerCase()
|
|
157
|
+
else if (arg === '--report') options.report = next()
|
|
158
|
+
else if (arg === '--checked-command') options.checkedCommand = next()
|
|
137
159
|
else if (arg === '--evidence-url') options.evidenceUrl = next()
|
|
138
160
|
else if (arg === '--evidence-file') options.evidenceFile = next()
|
|
139
161
|
else if (arg === '--evidence-sha256') options.evidenceSha256 = next().toLowerCase()
|
|
@@ -162,6 +184,123 @@ function readPackageMeta(directory) {
|
|
|
162
184
|
}
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
function firstLine(value) {
|
|
188
|
+
if (typeof value !== 'string') return null
|
|
189
|
+
const line = value.split(/\r?\n/)[0].trim()
|
|
190
|
+
return line === '' ? null : line
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function httpsUrl(value) {
|
|
194
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
195
|
+
let url = value.trim()
|
|
196
|
+
const shorthand = url.match(REPO_GIT_SHORTHAND)
|
|
197
|
+
if (shorthand) url = `https://github.com/${shorthand[1]}`
|
|
198
|
+
if (url.startsWith('git+')) url = url.slice(4)
|
|
199
|
+
if (url.startsWith('ssh://git@')) url = `https://${url.slice('ssh://git@'.length)}`
|
|
200
|
+
if (url.startsWith('git@')) {
|
|
201
|
+
const match = url.match(/^git@([^:]+):(.+)$/)
|
|
202
|
+
if (match) url = `https://${match[1]}/${match[2]}`
|
|
203
|
+
}
|
|
204
|
+
if (url.startsWith('git://')) url = `https://${url.slice('git://'.length)}`
|
|
205
|
+
if (url.startsWith('http://')) url = `https://${url.slice('http://'.length)}`
|
|
206
|
+
if (!url.startsWith('https://')) return null
|
|
207
|
+
url = url.replace(/\.git$/i, '').replace(/\/+$/, '')
|
|
208
|
+
try {
|
|
209
|
+
const parsed = new URL(url)
|
|
210
|
+
if (parsed.protocol !== 'https:') return null
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
return url
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeRepo(repository) {
|
|
218
|
+
if (typeof repository === 'string') return httpsUrl(repository)
|
|
219
|
+
if (!repository || typeof repository !== 'object') return null
|
|
220
|
+
return httpsUrl(repository.url)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeLicense(license) {
|
|
224
|
+
let value = license
|
|
225
|
+
if (value && typeof value === 'object') value = value.type
|
|
226
|
+
if (typeof value !== 'string') return null
|
|
227
|
+
const text = value.trim()
|
|
228
|
+
if (text === '' || /^SEE LICENSE/i.test(text) || text === 'UNLICENSED') return null
|
|
229
|
+
// Accept plain SPDX ids and simple AND/OR/WITH expressions; anything else is
|
|
230
|
+
// left null rather than guessed.
|
|
231
|
+
if (!/^[A-Za-z0-9.+-]+(?: \([^()]+\))?(?: (?:AND|OR|WITH) [A-Za-z0-9.+-]+(?: \([^()]+\))?)*$/.test(text)) return null
|
|
232
|
+
return text
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function firstString(...values) {
|
|
236
|
+
for (const value of values) {
|
|
237
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function platformFromPackage(meta) {
|
|
243
|
+
const declared = firstString(meta?.dsh?.client?.platform, meta?.dsh?.platform, meta?.engines?.platform)
|
|
244
|
+
if (declared) return declared
|
|
245
|
+
const os = Array.isArray(meta?.os) ? meta.os.filter(item => typeof item === 'string' && item !== '') : []
|
|
246
|
+
const cpu = Array.isArray(meta?.cpu) ? meta.cpu.filter(item => typeof item === 'string' && item !== '') : []
|
|
247
|
+
if (os.length === 0 && cpu.length === 0) return null
|
|
248
|
+
return [os.join('/'), cpu.join('/')].filter(Boolean).join('-')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function inferScope(meta, audit) {
|
|
252
|
+
const anchor = audit?.checker?.anchor && typeof audit.checker.anchor === 'object' ? audit.checker.anchor : {}
|
|
253
|
+
const dshEngines = meta?.dsh?.engines && typeof meta.dsh.engines === 'object' ? meta.dsh.engines : {}
|
|
254
|
+
const engines = meta?.engines && typeof meta.engines === 'object' ? meta.engines : {}
|
|
255
|
+
const peer = meta?.peerDependencies && typeof meta.peerDependencies === 'object' ? meta.peerDependencies : {}
|
|
256
|
+
const deps = meta?.dependencies && typeof meta.dependencies === 'object' ? meta.dependencies : {}
|
|
257
|
+
return {
|
|
258
|
+
dsh: firstString(dshEngines.dsh, engines.dsh, anchor.dsh),
|
|
259
|
+
cordis: firstString(dshEngines.cordis, engines.cordis, peer['@deepseek-ai/cordis'], deps['@deepseek-ai/cordis'], anchor.cordis),
|
|
260
|
+
platform: platformFromPackage(meta),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function extractChecks(report, name, version) {
|
|
265
|
+
if (!report || !Array.isArray(report.checks)) return []
|
|
266
|
+
if (report.subject && typeof report.subject === 'object') {
|
|
267
|
+
const subjectName = firstString(report.subject.name)
|
|
268
|
+
const subjectVersion = firstString(report.subject.version)
|
|
269
|
+
if (subjectName !== null && name !== null && subjectName !== name) {
|
|
270
|
+
throw new Error(`--report subject.name ${subjectName} does not match ${name}`)
|
|
271
|
+
}
|
|
272
|
+
if (subjectVersion !== null && version !== null && subjectVersion !== version) {
|
|
273
|
+
throw new Error(`--report subject.version ${subjectVersion} does not match ${version}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return report.checks.map(check => {
|
|
277
|
+
if (!check || typeof check !== 'object') throw new Error('--report checks must be objects')
|
|
278
|
+
const id = firstString(check.id)
|
|
279
|
+
const result = firstString(check.result)
|
|
280
|
+
const coverage = firstString(check.coverage)
|
|
281
|
+
if (id === null || result === null || coverage === null) {
|
|
282
|
+
throw new Error('--report checks need id/result/coverage')
|
|
283
|
+
}
|
|
284
|
+
return { id, result, coverage }
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function summarizeNotChecked(audit, checks, hasReport) {
|
|
289
|
+
const items = []
|
|
290
|
+
const entry = audit?.levels?.entry && typeof audit.levels.entry === 'object' ? audit.levels.entry : {}
|
|
291
|
+
if (entry.claim !== 'runtime') items.push(`ENTRY:runtime (claim=${firstString(entry.claim) ?? 'unknown'}; not exercised)`)
|
|
292
|
+
if (!hasReport) items.push('ENTRY:checks (no --report; checker results unavailable)')
|
|
293
|
+
const notRun = checks.filter(check => check.coverage === 'not-run').map(check => check.id)
|
|
294
|
+
if (notRun.length > 0) items.push(`ENTRY:${notRun.join(', ')} (coverage=not-run)`)
|
|
295
|
+
const flow = audit?.levels?.flow && typeof audit.levels.flow === 'object' ? audit.levels.flow : {}
|
|
296
|
+
if (flow.state !== 'runtime') {
|
|
297
|
+
items.push(`FLOW:J1-J8 (${firstString(flow.state) ?? 'not-run'}; ${Number.isInteger(flow.passed) ? flow.passed : 0}/8 journeys pass)`)
|
|
298
|
+
}
|
|
299
|
+
const boost = audit?.levels?.boost && typeof audit.levels.boost === 'object' ? audit.levels.boost : {}
|
|
300
|
+
if (boost.state !== 'measured') items.push(`BOOST:LEV-3..LEV-4 (${firstString(boost.state) ?? 'not-measured'})`)
|
|
301
|
+
return items
|
|
302
|
+
}
|
|
303
|
+
|
|
165
304
|
function pickLevel(row, keys, fallback) {
|
|
166
305
|
const level = {}
|
|
167
306
|
if (row && typeof row === 'object') {
|
|
@@ -245,6 +384,9 @@ function entryFromAudit(audit, options, levelFile) {
|
|
|
245
384
|
if (typeof name !== 'string' || name === '') throw new Error('cannot determine subject.name; pass --name or --package')
|
|
246
385
|
if (typeof version !== 'string' || version === '') throw new Error('cannot determine subject.version; pass --version or --package')
|
|
247
386
|
|
|
387
|
+
const report = options.report ? readJson(path.resolve(options.report), '--report') : null
|
|
388
|
+
const checks = extractChecks(report, name, version)
|
|
389
|
+
|
|
248
390
|
const auditLevels = audit.levels && typeof audit.levels === 'object' ? audit.levels : {}
|
|
249
391
|
const levels = {
|
|
250
392
|
entry: pickLevel(auditLevels.entry, ['state', 'claim'], 'not-run'),
|
|
@@ -286,16 +428,38 @@ function entryFromAudit(audit, options, levelFile) {
|
|
|
286
428
|
let parsed
|
|
287
429
|
try { parsed = new URL(options.evidenceUrl) } catch { throw new Error(`--evidence-url is not a URL: ${options.evidenceUrl}`) }
|
|
288
430
|
if (parsed.protocol !== 'https:') throw new Error('--evidence-url must be https')
|
|
431
|
+
// url/sha256 are the original names; reportUrl/reportSha256 are the same
|
|
432
|
+
// values under the newer, explicit names. Both stay so old readers work.
|
|
289
433
|
evidence.url = options.evidenceUrl
|
|
434
|
+
evidence.reportUrl = options.evidenceUrl
|
|
435
|
+
}
|
|
436
|
+
if (evidenceSha256 !== null) {
|
|
437
|
+
evidence.sha256 = evidenceSha256
|
|
438
|
+
evidence.reportSha256 = evidenceSha256
|
|
290
439
|
}
|
|
291
|
-
|
|
440
|
+
|
|
441
|
+
const scanner = firstString(audit.checker?.anchor?.scanner)
|
|
442
|
+
const checkerVersion = firstString(options.checkerVersion, readPackageMeta(ROOT).version) ?? 'unknown'
|
|
443
|
+
const checker = scanner === null
|
|
444
|
+
? `dsh-standards@${checkerVersion}`
|
|
445
|
+
: `dsh-standards@${checkerVersion} (${scanner})`
|
|
446
|
+
const command = firstString(options.checkedCommand) ?? (levels.entry.claim === 'runtime'
|
|
447
|
+
? 'dsh-levels <package-dir> --format json'
|
|
448
|
+
: 'dsh-levels <package-dir> --no-runtime --format json')
|
|
292
449
|
|
|
293
450
|
return {
|
|
294
451
|
key: `${name}@${version}`,
|
|
295
452
|
entry: {
|
|
296
453
|
subject: { name, version, tarballSha256 },
|
|
454
|
+
title: firstLine(meta.description),
|
|
455
|
+
repo: normalizeRepo(meta.repository),
|
|
456
|
+
license: normalizeLicense(meta.license),
|
|
457
|
+
scope: inferScope(meta, audit),
|
|
297
458
|
levels,
|
|
459
|
+
checks,
|
|
298
460
|
evidence,
|
|
461
|
+
checkedBy: { checker, command },
|
|
462
|
+
notChecked: summarizeNotChecked(audit, checks, report !== null),
|
|
299
463
|
validity: { checkedAt, expiresAt, policy: `${options.ttlDays}d` },
|
|
300
464
|
lifecycle: 'active',
|
|
301
465
|
selfAssessed: options.selfAssessed,
|
|
@@ -400,6 +564,72 @@ function validateSidecar(sidecar) {
|
|
|
400
564
|
if (entry.evidence.sha256 !== undefined && !HEX64.test(String(entry.evidence.sha256))) {
|
|
401
565
|
throw new Error(`${key}: evidence.sha256 must be 64 hex chars`)
|
|
402
566
|
}
|
|
567
|
+
if (entry.evidence.reportUrl !== undefined) {
|
|
568
|
+
let url
|
|
569
|
+
try { url = new URL(entry.evidence.reportUrl) } catch { throw new Error(`${key}: evidence.reportUrl is not a URL`) }
|
|
570
|
+
if (url.protocol !== 'https:') throw new Error(`${key}: evidence.reportUrl must be https`)
|
|
571
|
+
}
|
|
572
|
+
if (entry.evidence.reportSha256 !== undefined && !HEX64.test(String(entry.evidence.reportSha256))) {
|
|
573
|
+
throw new Error(`${key}: evidence.reportSha256 must be 64 hex chars`)
|
|
574
|
+
}
|
|
575
|
+
if (entry.title !== undefined && entry.title !== null && (typeof entry.title !== 'string' || entry.title === '')) {
|
|
576
|
+
throw new Error(`${key}: title must be a non-empty string or null`)
|
|
577
|
+
}
|
|
578
|
+
if (entry.repo !== undefined && entry.repo !== null) {
|
|
579
|
+
let url
|
|
580
|
+
try { url = new URL(entry.repo) } catch { throw new Error(`${key}: repo is not a URL`) }
|
|
581
|
+
if (url.protocol !== 'https:') throw new Error(`${key}: repo must be https or null`)
|
|
582
|
+
}
|
|
583
|
+
if (entry.license !== undefined && entry.license !== null && (typeof entry.license !== 'string' || entry.license === '')) {
|
|
584
|
+
throw new Error(`${key}: license must be a non-empty string or null`)
|
|
585
|
+
}
|
|
586
|
+
if (entry.scope !== undefined) {
|
|
587
|
+
if (!entry.scope || typeof entry.scope !== 'object' || Array.isArray(entry.scope)) throw new Error(`${key}: scope must be an object`)
|
|
588
|
+
for (const field of Object.keys(entry.scope)) {
|
|
589
|
+
if (!SCOPE_KEYS.has(field)) throw new Error(`${key}: scope.${field} is not part of the sidecar contract`)
|
|
590
|
+
}
|
|
591
|
+
for (const field of ['dsh', 'cordis', 'platform']) {
|
|
592
|
+
const value = entry.scope[field]
|
|
593
|
+
if (field === 'platform' && value === undefined) continue
|
|
594
|
+
if (value !== null && (typeof value !== 'string' || value === '')) {
|
|
595
|
+
throw new Error(`${key}: scope.${field} must be a non-empty string or null`)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (entry.checks !== undefined) {
|
|
600
|
+
if (!Array.isArray(entry.checks)) throw new Error(`${key}: checks must be an array`)
|
|
601
|
+
const seen = new Set()
|
|
602
|
+
for (const check of entry.checks) {
|
|
603
|
+
if (!check || typeof check !== 'object' || Array.isArray(check)) throw new Error(`${key}: every check must be an object`)
|
|
604
|
+
for (const field of Object.keys(check)) {
|
|
605
|
+
if (!CHECK_KEYS.has(field)) throw new Error(`${key}: checks must not carry ${field}; only id/result/coverage`)
|
|
606
|
+
}
|
|
607
|
+
for (const field of ['id', 'result', 'coverage']) {
|
|
608
|
+
if (typeof check[field] !== 'string' || check[field] === '') throw new Error(`${key}: checks[].${field} is required`)
|
|
609
|
+
}
|
|
610
|
+
if (seen.has(check.id)) throw new Error(`${key}: duplicate check id ${check.id}`)
|
|
611
|
+
seen.add(check.id)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (entry.checkedBy !== undefined) {
|
|
615
|
+
if (!entry.checkedBy || typeof entry.checkedBy !== 'object' || Array.isArray(entry.checkedBy)) {
|
|
616
|
+
throw new Error(`${key}: checkedBy must be an object`)
|
|
617
|
+
}
|
|
618
|
+
for (const field of Object.keys(entry.checkedBy)) {
|
|
619
|
+
if (!CHECKED_BY_KEYS.has(field)) throw new Error(`${key}: checkedBy.${field} is not part of the sidecar contract`)
|
|
620
|
+
}
|
|
621
|
+
for (const field of ['checker', 'command']) {
|
|
622
|
+
if (typeof entry.checkedBy[field] !== 'string' || entry.checkedBy[field] === '') {
|
|
623
|
+
throw new Error(`${key}: checkedBy.${field} is required`)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (entry.notChecked !== undefined) {
|
|
628
|
+
if (!Array.isArray(entry.notChecked)) throw new Error(`${key}: notChecked must be an array`)
|
|
629
|
+
for (const item of entry.notChecked) {
|
|
630
|
+
if (typeof item !== 'string' || item === '') throw new Error(`${key}: notChecked entries must be non-empty strings`)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
403
633
|
}
|
|
404
634
|
walkKeys(sidecar, 'sidecar', (key, where) => {
|
|
405
635
|
if (FORBIDDEN.has(key)) throw new Error(`${where} is forbidden in a sidecar (${[...FORBIDDEN].join(', ')})`)
|
|
@@ -5,22 +5,40 @@
|
|
|
5
5
|
* same report is presented: Markdown by default, `--json` for the raw
|
|
6
6
|
* `dsh-conformance/1` document, `--html <file>` for a single self-contained
|
|
7
7
|
* file. Exit codes are the checker's, never rewritten here.
|
|
8
|
+
*
|
|
9
|
+
* Safety defaults live here, not in the checker:
|
|
10
|
+
* - unless the caller explicitly opts in, `--entry-claim static --no-exec` is
|
|
11
|
+
* forwarded, so `check` never imports the inspected plugin;
|
|
12
|
+
* - the PATH `dsh` is never executed unconditionally. The anchor comes from
|
|
13
|
+
* `--host-version`, then `$DSH_HOST_VERSION`, then an opt-in
|
|
14
|
+
* `--allow-host-probe` (which resolves the `dsh` JS entry and runs it with
|
|
15
|
+
* `node ... --version`). With no source the report says the host version is
|
|
16
|
+
* unknown and records `anchor.runtimeSource=default`, `suspect=true`.
|
|
8
17
|
*/
|
|
9
18
|
import fs from 'node:fs'
|
|
19
|
+
import os from 'node:os'
|
|
10
20
|
import path from 'node:path'
|
|
11
21
|
import { spawnSync } from 'node:child_process'
|
|
22
|
+
import { createRequire } from 'node:module'
|
|
12
23
|
import { fileURLToPath } from 'node:url'
|
|
13
24
|
import { renderHtml, renderMarkdown } from './author-report.mjs'
|
|
25
|
+
import { sanitizedEnv } from './sandbox-env.mjs'
|
|
26
|
+
import { parseVersion } from './semver.mjs'
|
|
14
27
|
|
|
15
28
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
16
29
|
const CHECKER = path.resolve(HERE, '..', 'bin', 'dsh-plugin-verify.mjs')
|
|
17
30
|
const MAX_BUFFER = 64 * 1024 * 1024
|
|
31
|
+
const HOST_PROBE_TIMEOUT_MS = 5000
|
|
32
|
+
const RUNTIME_IDS = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
|
|
33
|
+
// The checker falls back to `dsh --version` when DSH_BIN is unset. `check` must
|
|
34
|
+
// never do that implicitly, so the child always gets a path that cannot exist.
|
|
35
|
+
const NO_PROBE_BIN = path.join(os.tmpdir(), `dsh-standards-no-host-probe-${process.pid}`)
|
|
18
36
|
|
|
19
37
|
/** Checker flags that take a value, so `check --scope entry` is not read as a directory. */
|
|
20
38
|
const VALUE_FLAGS = new Set([
|
|
21
39
|
'--scope', '--profile', '--runtime', '--runtime-version', '--cordis-version', '--commit',
|
|
22
40
|
'--install-spec', '--market-index', '--plugin-name', '--timeout', '--host', '--base',
|
|
23
|
-
'--web', '--bare', '--entry-runtime', '--entry-claim',
|
|
41
|
+
'--web', '--bare', '--entry-runtime', '--entry-claim', '--max-files', '--max-bytes',
|
|
24
42
|
])
|
|
25
43
|
|
|
26
44
|
export function checkUsage() {
|
|
@@ -34,8 +52,17 @@ Usage:
|
|
|
34
52
|
Options:
|
|
35
53
|
--json print the raw dsh-conformance/1 report
|
|
36
54
|
--html <file> write a single-file HTML report (inline CSS, no assets)
|
|
55
|
+
--host-version <v> host dsh version for the anchor (default: $DSH_HOST_VERSION)
|
|
56
|
+
--allow-host-probe resolve the \`dsh\` JS entry and run \`node <entry> --version\`
|
|
37
57
|
--help show this help
|
|
38
58
|
|
|
59
|
+
Safety:
|
|
60
|
+
check defaults to --entry-claim static --no-exec: the inspected plugin code
|
|
61
|
+
is NOT executed. Runtime checks need an explicit --runtime <path>, --host <file>,
|
|
62
|
+
or --entry-claim runtime. PATH \`dsh\` is never executed unless --allow-host-probe
|
|
63
|
+
is passed; with no host version the report says "host 版本未知" and marks the
|
|
64
|
+
anchor source=default, suspect=true.
|
|
65
|
+
|
|
39
66
|
Any other flag is forwarded to the checker, e.g.:
|
|
40
67
|
--scope entry --entry-claim static --no-exec
|
|
41
68
|
--runtime-version 0.2.2-rc.1
|
|
@@ -49,12 +76,162 @@ Exit codes (same as the checker):
|
|
|
49
76
|
`
|
|
50
77
|
}
|
|
51
78
|
|
|
79
|
+
function hasFlag(args, name) {
|
|
80
|
+
return args.some(arg => arg === name || arg.startsWith(`${name}=`))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function flagValue(args, name) {
|
|
84
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
85
|
+
const arg = args[index]
|
|
86
|
+
if (arg === name) return args[index + 1]
|
|
87
|
+
if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
|
|
88
|
+
}
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The one place that decides whether a run may execute plugin code.
|
|
94
|
+
*
|
|
95
|
+
* `--runtime`, `--host` and `--entry-claim runtime` are explicit opt-ins. Every
|
|
96
|
+
* other invocation gets `--no-exec`; a missing entry claim also gets the static
|
|
97
|
+
* claim so DSC-14 cannot pretend runtime evidence exists.
|
|
98
|
+
*/
|
|
99
|
+
export function safetyDefaultsFor(passthrough = []) {
|
|
100
|
+
const entryClaim = flagValue(passthrough, '--entry-claim')
|
|
101
|
+
const claimProvided = hasFlag(passthrough, '--entry-claim')
|
|
102
|
+
const runtimeRequested = hasFlag(passthrough, '--runtime')
|
|
103
|
+
|| hasFlag(passthrough, '--host')
|
|
104
|
+
|| entryClaim === 'runtime'
|
|
105
|
+
const noExecRequested = hasFlag(passthrough, '--no-exec')
|
|
106
|
+
const args = []
|
|
107
|
+
if (!runtimeRequested) {
|
|
108
|
+
if (!claimProvided) args.push('--entry-claim', 'static')
|
|
109
|
+
if (!noExecRequested) args.push('--no-exec')
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
args,
|
|
113
|
+
defaulted: !runtimeRequested,
|
|
114
|
+
runtimeRequested,
|
|
115
|
+
entryClaim: entryClaim ?? (runtimeRequested ? 'runtime' : 'static'),
|
|
116
|
+
noExec: runtimeRequested ? noExecRequested : true,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeVersion(value, label) {
|
|
121
|
+
const normalized = String(value ?? '').trim().replace(/^v/, '')
|
|
122
|
+
if (normalized === '' || parseVersion(normalized) === null) {
|
|
123
|
+
throw new Error(`${label} must be an exact semver version`)
|
|
124
|
+
}
|
|
125
|
+
return normalized
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function realpathOrNull(value) {
|
|
129
|
+
try { return fs.realpathSync(value) } catch { return null }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Turn a `dsh` PATH entry into the JS file that `node` can run. npm installs a
|
|
134
|
+
* shell/cmd shim, so running the shim itself with node would fail; the shim's
|
|
135
|
+
* text points at `node_modules/@deepseek-ai/dsh/lib/bin.js`.
|
|
136
|
+
*/
|
|
137
|
+
function nodeEntryOf(candidate) {
|
|
138
|
+
const resolved = realpathOrNull(candidate)
|
|
139
|
+
if (!resolved) return null
|
|
140
|
+
if (/\.(?:mjs|cjs|js)$/i.test(resolved)) return resolved
|
|
141
|
+
let head = ''
|
|
142
|
+
try { head = fs.readFileSync(resolved, 'utf8').slice(0, 8192) } catch { return null }
|
|
143
|
+
if (/^#![^\n]*\bnode\b/m.test(head)) return resolved
|
|
144
|
+
const shimRelative = head.match(/node_modules[\\/]@deepseek-ai[\\/]dsh[\\/][^\s"']+\.js/)
|
|
145
|
+
if (shimRelative) {
|
|
146
|
+
const fromShim = path.join(path.dirname(resolved), shimRelative[0].replace(/\\/g, '/'))
|
|
147
|
+
if (fs.existsSync(fromShim)) return fromShim
|
|
148
|
+
}
|
|
149
|
+
const fallback = path.join(path.dirname(resolved), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
|
|
150
|
+
return fs.existsSync(fallback) ? fallback : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveDshEntry(env = process.env) {
|
|
154
|
+
const candidates = []
|
|
155
|
+
if (typeof env.DSH_BIN === 'string' && env.DSH_BIN.trim() !== '') candidates.push(env.DSH_BIN.trim())
|
|
156
|
+
try {
|
|
157
|
+
const require = createRequire(path.join(process.cwd(), 'noop.cjs'))
|
|
158
|
+
candidates.push(require.resolve('@deepseek-ai/dsh/lib/bin.js'))
|
|
159
|
+
} catch { /* not installed next to the caller */ }
|
|
160
|
+
const names = process.platform === 'win32' ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh']
|
|
161
|
+
for (const dir of String(env.PATH ?? env.Path ?? '').split(path.delimiter)) {
|
|
162
|
+
if (dir.trim() === '') continue
|
|
163
|
+
for (const name of names) candidates.push(path.join(dir, name))
|
|
164
|
+
}
|
|
165
|
+
for (const candidate of candidates) {
|
|
166
|
+
const entry = nodeEntryOf(candidate)
|
|
167
|
+
if (entry) return entry
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Opt-in only: resolve `dsh` and run its JS entry as `node <entry> --version`. */
|
|
173
|
+
export function probeHostVersion(env = process.env) {
|
|
174
|
+
const entry = resolveDshEntry(env)
|
|
175
|
+
if (!entry) return { version: null, source: 'none', error: 'no resolvable dsh JS entry' }
|
|
176
|
+
const run = spawnSync(process.execPath, [entry, '--version'], {
|
|
177
|
+
encoding: 'utf8',
|
|
178
|
+
timeout: HOST_PROBE_TIMEOUT_MS,
|
|
179
|
+
env: sanitizedEnv(),
|
|
180
|
+
})
|
|
181
|
+
if (run.error || run.status !== 0) {
|
|
182
|
+
return { version: null, source: 'none', error: run.error?.message ?? `dsh --version exited ${run.status}` }
|
|
183
|
+
}
|
|
184
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(`${run.stdout ?? ''}\n${run.stderr ?? ''}`)
|
|
185
|
+
if (!match) return { version: null, source: 'none', error: 'no semver in the probed dsh output' }
|
|
186
|
+
return { version: match[1], source: 'probe', entry, error: null }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Priority: explicit flag, environment, opt-in probe, then unknown. An invalid
|
|
191
|
+
* environment value is reported as unknown rather than failing the whole run.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveHostVersion({ hostVersion = null, allowHostProbe = false, env = process.env } = {}) {
|
|
194
|
+
if (hostVersion !== null && hostVersion !== undefined) {
|
|
195
|
+
return { version: normalizeVersion(hostVersion, '--host-version'), source: 'flag', error: null }
|
|
196
|
+
}
|
|
197
|
+
const fromEnv = typeof env.DSH_HOST_VERSION === 'string' ? env.DSH_HOST_VERSION.trim() : ''
|
|
198
|
+
if (fromEnv !== '') {
|
|
199
|
+
try {
|
|
200
|
+
return { version: normalizeVersion(fromEnv, 'DSH_HOST_VERSION'), source: 'env', error: null }
|
|
201
|
+
} catch {
|
|
202
|
+
return { version: null, source: 'none', error: 'DSH_HOST_VERSION is not an exact semver; ignored' }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (allowHostProbe) return probeHostVersion(env)
|
|
206
|
+
return { version: null, source: 'none', error: 'host version unknown' }
|
|
207
|
+
}
|
|
208
|
+
|
|
52
209
|
export function parseCheckArgs(argv = []) {
|
|
53
|
-
const options = {
|
|
210
|
+
const options = {
|
|
211
|
+
dir: null, json: false, html: null, help: false, hostVersion: null, allowHostProbe: false, passthrough: [],
|
|
212
|
+
}
|
|
54
213
|
for (let index = 0; index < argv.length; index += 1) {
|
|
55
214
|
const arg = argv[index]
|
|
56
215
|
if (arg === '--help' || arg === '-h') { options.help = true; continue }
|
|
57
216
|
if (arg === '--json') { options.json = true; continue }
|
|
217
|
+
if (arg === '--allow-host-probe' || arg.startsWith('--allow-host-probe=')) {
|
|
218
|
+
const inline = arg.startsWith('--allow-host-probe=') ? arg.slice('--allow-host-probe='.length) : undefined
|
|
219
|
+
options.allowHostProbe = inline === undefined ? true : inline !== 'false'
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (arg === '--host-version' || arg.startsWith('--host-version=')) {
|
|
223
|
+
const inline = arg.startsWith('--host-version=') ? arg.slice('--host-version='.length) : undefined
|
|
224
|
+
if (inline !== undefined) {
|
|
225
|
+
if (inline.trim() === '') throw new Error('--host-version needs a value')
|
|
226
|
+
options.hostVersion = inline
|
|
227
|
+
} else {
|
|
228
|
+
const next = argv[index + 1]
|
|
229
|
+
if (next === undefined || next.startsWith('--')) throw new Error('--host-version needs a value')
|
|
230
|
+
options.hostVersion = next
|
|
231
|
+
index += 1
|
|
232
|
+
}
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
58
235
|
if (arg === '--html' || arg.startsWith('--html=')) {
|
|
59
236
|
const inline = arg.startsWith('--html=') ? arg.slice('--html='.length) : undefined
|
|
60
237
|
if (inline !== undefined) {
|
|
@@ -87,12 +264,20 @@ export function parseCheckArgs(argv = []) {
|
|
|
87
264
|
if (options.json && options.html) throw new Error('--json and --html are mutually exclusive')
|
|
88
265
|
if (options.dir === null) options.dir = '.'
|
|
89
266
|
options.dir = path.resolve(options.dir)
|
|
267
|
+
if (options.hostVersion !== null) options.hostVersion = normalizeVersion(options.hostVersion, '--host-version')
|
|
90
268
|
return options
|
|
91
269
|
}
|
|
92
270
|
|
|
93
|
-
function invokeChecker(options) {
|
|
94
|
-
const args = [CHECKER, options.dir, '--format', 'json', ...
|
|
95
|
-
const run = spawnSync(process.execPath, args, {
|
|
271
|
+
function invokeChecker(options, forwarded) {
|
|
272
|
+
const args = [CHECKER, options.dir, '--format', 'json', ...forwarded]
|
|
273
|
+
const run = spawnSync(process.execPath, args, {
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
timeout: 600_000,
|
|
276
|
+
maxBuffer: MAX_BUFFER,
|
|
277
|
+
// Never let the checker fall back to the PATH `dsh`: the anchor is resolved
|
|
278
|
+
// here, under --host-version / DSH_HOST_VERSION / --allow-host-probe.
|
|
279
|
+
env: sanitizedEnv({ DSH_BIN: NO_PROBE_BIN }),
|
|
280
|
+
})
|
|
96
281
|
if (run.error) throw new Error(`could not run the checker: ${run.error.message}`)
|
|
97
282
|
let report
|
|
98
283
|
try {
|
|
@@ -103,6 +288,79 @@ function invokeChecker(options) {
|
|
|
103
288
|
return { report, status: run.status }
|
|
104
289
|
}
|
|
105
290
|
|
|
291
|
+
function applyHostAnchor(report, options, hostInfo) {
|
|
292
|
+
const anchor = report.anchor ?? {}
|
|
293
|
+
const runtimeVersionExplicit = hasFlag(options.passthrough, '--runtime-version')
|
|
294
|
+
if (hostInfo.version) {
|
|
295
|
+
if (!runtimeVersionExplicit) {
|
|
296
|
+
anchor.dsh = hostInfo.version
|
|
297
|
+
anchor.runtimeSource = 'host'
|
|
298
|
+
anchor.suspect = false
|
|
299
|
+
} else {
|
|
300
|
+
anchor.suspect = anchor.dsh !== hostInfo.version
|
|
301
|
+
}
|
|
302
|
+
anchor.hostVersion = hostInfo.version
|
|
303
|
+
anchor.hostVersionKnown = true
|
|
304
|
+
anchor.hostVersionSource = hostInfo.source
|
|
305
|
+
} else {
|
|
306
|
+
if (!runtimeVersionExplicit) {
|
|
307
|
+
anchor.runtimeSource = 'default'
|
|
308
|
+
anchor.suspect = true
|
|
309
|
+
}
|
|
310
|
+
anchor.hostVersion = null
|
|
311
|
+
anchor.hostVersionKnown = false
|
|
312
|
+
anchor.hostVersionSource = 'none'
|
|
313
|
+
}
|
|
314
|
+
report.anchor = anchor
|
|
315
|
+
return runtimeVersionExplicit
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function applySafety(report, options, safety, hostInfo, runtimeVersionExplicit) {
|
|
319
|
+
applyHostAnchor(report, options, hostInfo)
|
|
320
|
+
const runtimeRan = (report.checks ?? []).some(check => RUNTIME_IDS.has(check.id)
|
|
321
|
+
&& ['PASS', 'WARN', 'FAIL'].includes(check.result)
|
|
322
|
+
&& check.coverage !== 'not-run')
|
|
323
|
+
const hostSmoke = hasFlag(options.passthrough, '--host') && !hasFlag(options.passthrough, '--no-exec')
|
|
324
|
+
const executed = !safety.noExec && (runtimeRan || hostSmoke)
|
|
325
|
+
report.safety = {
|
|
326
|
+
executed,
|
|
327
|
+
defaultSafety: safety.defaulted,
|
|
328
|
+
entryClaim: safety.entryClaim,
|
|
329
|
+
noExec: !executed,
|
|
330
|
+
hostVersionKnown: Boolean(hostInfo.version),
|
|
331
|
+
hostVersionSource: hostInfo.source,
|
|
332
|
+
hostProbeAllowed: options.allowHostProbe,
|
|
333
|
+
runtimeVersionExplicit,
|
|
334
|
+
}
|
|
335
|
+
if (hostInfo.error) report.safety.hostVersionError = hostInfo.error
|
|
336
|
+
return report
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function safetyBanner(report) {
|
|
340
|
+
const safety = report.safety ?? {}
|
|
341
|
+
const anchor = report.anchor ?? {}
|
|
342
|
+
const lines = []
|
|
343
|
+
if (safety.executed) {
|
|
344
|
+
lines.push('> ⚠️ 本次已执行插件代码(显式开启运行时;L1–L3 在隔离子进程中运行,超时硬杀)。')
|
|
345
|
+
} else if (safety.defaultSafety) {
|
|
346
|
+
lines.push('> ⚠️ 本次未执行插件代码(默认安全模式:`--entry-claim static --no-exec`;运行时检查记为 not-run,不是 PASS)。')
|
|
347
|
+
} else {
|
|
348
|
+
lines.push('> ⚠️ 本次未执行插件代码(运行时检查没有产生完整装载证据;not-run 不是 PASS)。')
|
|
349
|
+
}
|
|
350
|
+
if (!safety.hostVersionKnown) {
|
|
351
|
+
lines.push(`> host 版本未知:anchor.dsh=\`${anchor.dsh ?? '?'}\` 是默认值(source=${anchor.runtimeSource ?? 'default'}, suspect=${anchor.suspect ?? true})。用 \`--host-version\`、\`DSH_HOST_VERSION\` 或 \`--allow-host-probe\` 指定宿主版本。`)
|
|
352
|
+
} else {
|
|
353
|
+
lines.push(`> host 版本 \`${anchor.dsh}\`(来源:${anchor.hostVersionSource ?? 'host'});anchor.source=${anchor.runtimeSource ?? 'host'}。`)
|
|
354
|
+
}
|
|
355
|
+
return lines.join('\n')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function insertBanner(markdown, banner) {
|
|
359
|
+
const newline = markdown.indexOf('\n')
|
|
360
|
+
if (newline < 0) return `${markdown}\n\n${banner}\n`
|
|
361
|
+
return `${markdown.slice(0, newline + 1)}\n${banner}\n${markdown.slice(newline + 1)}`
|
|
362
|
+
}
|
|
363
|
+
|
|
106
364
|
export function runCheck(argv = process.argv.slice(2)) {
|
|
107
365
|
let options
|
|
108
366
|
try {
|
|
@@ -117,13 +375,22 @@ export function runCheck(argv = process.argv.slice(2)) {
|
|
|
117
375
|
return 0
|
|
118
376
|
}
|
|
119
377
|
let result
|
|
378
|
+
let hostInfo
|
|
379
|
+
let safety
|
|
120
380
|
try {
|
|
121
|
-
|
|
381
|
+
safety = safetyDefaultsFor(options.passthrough)
|
|
382
|
+
hostInfo = resolveHostVersion({ hostVersion: options.hostVersion, allowHostProbe: options.allowHostProbe })
|
|
383
|
+
const forwarded = [...safety.args, ...options.passthrough]
|
|
384
|
+
if (hostInfo.version && !hasFlag(options.passthrough, '--runtime-version')) {
|
|
385
|
+
forwarded.push('--runtime-version', hostInfo.version)
|
|
386
|
+
}
|
|
387
|
+
result = invokeChecker(options, forwarded)
|
|
122
388
|
} catch (error) {
|
|
123
389
|
process.stderr.write(`dsh-standards check: ${error.message}\n`)
|
|
124
390
|
return 3
|
|
125
391
|
}
|
|
126
392
|
const { report, status } = result
|
|
393
|
+
applySafety(report, options, safety, hostInfo, hasFlag(options.passthrough, '--runtime-version'))
|
|
127
394
|
const exitCode = Number.isInteger(report?.conclusion?.exitCode) ? report.conclusion.exitCode : (status ?? 3)
|
|
128
395
|
if (options.html) {
|
|
129
396
|
const out = path.resolve(options.html)
|
|
@@ -133,7 +400,7 @@ export function runCheck(argv = process.argv.slice(2)) {
|
|
|
133
400
|
} else if (options.json) {
|
|
134
401
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
135
402
|
} else {
|
|
136
|
-
process.stdout.write(renderMarkdown(report))
|
|
403
|
+
process.stdout.write(insertBanner(renderMarkdown(report), safetyBanner(report)))
|
|
137
404
|
}
|
|
138
405
|
return exitCode
|
|
139
406
|
}
|