dsh-standards 1.2.2 → 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/README.md +18 -1
- package/40-checker/scripts/build-offline-kit.mjs +284 -0
- package/40-checker/scripts/build-sidecar.mjs +714 -0
- package/40-checker/scripts/flow-collect.mjs +7 -1
- package/40-checker/scripts/level-audit.mjs +45 -7
- package/40-checker/scripts/run-tests.mjs +3 -7
- package/40-checker/src/author-cli.mjs +406 -0
- package/40-checker/src/author-report.mjs +271 -0
- package/40-checker/src/bundle-static.mjs +181 -0
- package/40-checker/src/cli.mjs +100 -11
- package/40-checker/src/client-shape.mjs +33 -3
- package/40-checker/src/declarations.mjs +53 -8
- package/40-checker/src/flow-evidence.mjs +106 -1
- package/40-checker/src/index.mjs +6 -4
- package/40-checker/src/manifest.mjs +87 -14
- package/40-checker/src/remediation.mjs +108 -7
- package/40-checker/src/report.mjs +203 -26
- package/40-checker/src/semver.mjs +112 -65
- package/40-checker/src/util.mjs +51 -8
- package/CHANGELOG.md +44 -0
- package/README.en.md +7 -2
- package/README.es.md +7 -2
- package/README.hi.md +7 -2
- package/README.md +35 -18
- package/README.pt.md +7 -2
- package/bin/dsh-standards.mjs +13 -2
- package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
- package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
- package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
- package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
- package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
- package/docs/coverage.md +82 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- 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/flow-evidence.schema.json +6 -2
- package/spec/remediation.json +54 -54
- package/spec/schemas/advice.schema.json +92 -0
- package/spec/schemas/boost-card.schema.json +107 -0
- package/spec/schemas/conformance.schema.json +848 -0
package/40-checker/src/cli.mjs
CHANGED
|
@@ -4,33 +4,45 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
|
|
5
5
|
import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
|
|
6
6
|
import { runRuntimeChecks } from './runtime.mjs'
|
|
7
|
+
import { runStaticBundleChecks } from './bundle-static.mjs'
|
|
7
8
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
9
|
import { sanitizedEnv } from './sandbox-env.mjs'
|
|
9
|
-
import {
|
|
10
|
+
import { readSourcesWithStats } from './util.mjs'
|
|
10
11
|
import { buildReport, writeReport } from './report.mjs'
|
|
11
12
|
|
|
12
13
|
const VERSION = '0.1.0'
|
|
13
14
|
const DEFAULT_COMMIT = 'ce7073d51254c26cd63b22a60835b8f948deb447'
|
|
14
15
|
const DEFAULT_CORDIS = '4.0.6-alpha.1'
|
|
16
|
+
const HOST_RUNTIME_TIMEOUT_MS = 5000
|
|
17
|
+
|
|
18
|
+
function binName() {
|
|
19
|
+
const brand = process.env.DSH_CHECKER_BRAND
|
|
20
|
+
if (typeof brand === 'string' && brand.trim() !== '') return brand.trim().split(/\s+/)[0]
|
|
21
|
+
return 'dsh-plugin-verify'
|
|
22
|
+
}
|
|
15
23
|
|
|
16
24
|
function usage() {
|
|
25
|
+
const bin = binName()
|
|
17
26
|
return `${process.env.DSH_CHECKER_BRAND || 'dsh-plugin-verify ' + VERSION} — DS-C conformance checker (MVP)
|
|
18
27
|
|
|
19
28
|
Usage:
|
|
20
|
-
|
|
29
|
+
${bin} check [dir] [--json] [--html <file>] author-facing report (Markdown by default)
|
|
30
|
+
${bin} <package-dir> [options] raw conformance report
|
|
21
31
|
|
|
22
32
|
Options:
|
|
23
33
|
--format text|json|sarif output format (default: text)
|
|
24
34
|
--scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
|
|
25
35
|
--profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
|
|
26
36
|
--runtime <path> path to a built @deepseek-ai/cordis lib/index.js; enables L1-L3
|
|
27
|
-
--runtime-version <v> dsh runtime version used for C-2 (default: ${DEFAULT_RUNTIME_VERSION})
|
|
37
|
+
--runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
|
|
28
38
|
--cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
|
|
29
39
|
--commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
|
|
30
40
|
--install-spec <spec> recommended install spec for C-3 (listing/install check)
|
|
31
41
|
--market-index <file> format-1 market index JSON for C-3 listing mode
|
|
32
42
|
--plugin-name <name> plugin name inside the market index
|
|
33
43
|
--timeout <ms> L1-L3 timeout (default: 10000)
|
|
44
|
+
--max-files <n> source scan file budget (default: 400)
|
|
45
|
+
--max-bytes <n> source scan byte budget (default: 2000000)
|
|
34
46
|
--no-exec skip L1-L3 entirely (reported as not-run/NA, never PASS)
|
|
35
47
|
--static-only M1 mode: listing profile, only C-1/C-2/C-3/C-11/C-12/C-13; C-4..C-9 are out of scope
|
|
36
48
|
--treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
|
|
@@ -61,13 +73,15 @@ function parseArgs(argv) {
|
|
|
61
73
|
scope: 'ds-c',
|
|
62
74
|
profile: null,
|
|
63
75
|
runtime: null,
|
|
64
|
-
runtimeVersion:
|
|
76
|
+
runtimeVersion: null,
|
|
65
77
|
cordisVersion: DEFAULT_CORDIS,
|
|
66
78
|
commit: DEFAULT_COMMIT,
|
|
67
79
|
installSpec: null,
|
|
68
80
|
marketIndex: null,
|
|
69
81
|
pluginName: null,
|
|
70
82
|
timeoutMs: 10_000,
|
|
83
|
+
maxFiles: 400,
|
|
84
|
+
maxBytes: 2_000_000,
|
|
71
85
|
noExec: false,
|
|
72
86
|
staticOnly: false,
|
|
73
87
|
treatAsPlugin: false,
|
|
@@ -111,6 +125,8 @@ function parseArgs(argv) {
|
|
|
111
125
|
case '--market-index': options.marketIndex = value(); break
|
|
112
126
|
case '--plugin-name': options.pluginName = value(); break
|
|
113
127
|
case '--timeout': options.timeoutMs = Number(value()); break
|
|
128
|
+
case '--max-files': options.maxFiles = Number(value()); break
|
|
129
|
+
case '--max-bytes': options.maxBytes = Number(value()); break
|
|
114
130
|
case '--out': options.out = value(); break
|
|
115
131
|
case '--no-exec': options.noExec = true; break
|
|
116
132
|
case '--static-only': options.staticOnly = true; break
|
|
@@ -135,6 +151,8 @@ function parseArgs(argv) {
|
|
|
135
151
|
if (!['package', 'listing', 'both'].includes(options.profile)) throw new Error(`unsupported --profile: ${options.profile}`)
|
|
136
152
|
if (options.staticOnly && options.treatAsPlugin) throw new Error('--static-only and --treat-as-plugin are mutually exclusive')
|
|
137
153
|
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 120_000) throw new Error('--timeout must be between 100 and 120000 ms')
|
|
154
|
+
if (!Number.isInteger(options.maxFiles) || options.maxFiles < 1 || options.maxFiles > 200_000) throw new Error('--max-files must be an integer between 1 and 200000')
|
|
155
|
+
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1000 || options.maxBytes > 2_000_000_000) throw new Error('--max-bytes must be an integer between 1000 and 2000000000')
|
|
138
156
|
if (options.target === null) options.target = '.'
|
|
139
157
|
return options
|
|
140
158
|
}
|
|
@@ -223,14 +241,66 @@ async function entryRunCheck(kind, runtimeChecks, options, dir) {
|
|
|
223
241
|
return { ...base, result: 'ERROR', coverage: 'not-run', reason: c4?.reason || 'no runtime evidence' }
|
|
224
242
|
}
|
|
225
243
|
|
|
226
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Read the runtime version the host actually runs. The anchor used to be a
|
|
246
|
+
* hard-coded `0.2.2-alpha.2`, which manufactured dozens of C-2.2 failures on a
|
|
247
|
+
* host that had already moved on. Detection is best-effort: when `dsh` is not
|
|
248
|
+
* usable the anchor falls back to the checked-in constant and says so.
|
|
249
|
+
*/
|
|
250
|
+
export function detectHostRuntime({ bin = process.env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh'), timeoutMs = HOST_RUNTIME_TIMEOUT_MS } = {}) {
|
|
251
|
+
const run = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: timeoutMs })
|
|
252
|
+
if (run.error || run.status !== 0) return { version: null, bin, error: run.error?.message ?? `exit ${run.status}` }
|
|
253
|
+
const text = `${run.stdout ?? ''}\n${run.stderr ?? ''}`
|
|
254
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(text)
|
|
255
|
+
if (!match) return { version: null, bin, error: 'no semver in `dsh --version` output' }
|
|
256
|
+
return { version: match[1], bin, error: null }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve what `anchor.dsh` means:
|
|
261
|
+
* cli the caller passed --runtime-version; suspect when it differs from the host
|
|
262
|
+
* host no flag: the host version is the anchor, so it cannot be suspect
|
|
263
|
+
* fallback dsh unavailable: keep the constant, flagged as not host-derived
|
|
264
|
+
*/
|
|
265
|
+
export function resolveRuntimeAnchor(requestedVersion = null, hostVersion = null) {
|
|
266
|
+
if (typeof requestedVersion === 'string' && requestedVersion.trim() !== '') {
|
|
267
|
+
const requested = requestedVersion.trim()
|
|
268
|
+
return {
|
|
269
|
+
dsh: requested,
|
|
270
|
+
runtimeSource: 'cli',
|
|
271
|
+
hostVersion: hostVersion ?? null,
|
|
272
|
+
suspect: typeof hostVersion === 'string' && hostVersion !== '' && hostVersion !== requested,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (typeof hostVersion === 'string' && hostVersion !== '') {
|
|
276
|
+
return { dsh: hostVersion, runtimeSource: 'host', hostVersion, suspect: false }
|
|
277
|
+
}
|
|
278
|
+
return { dsh: DEFAULT_RUNTIME_VERSION, runtimeSource: 'fallback', hostVersion: null, suspect: false }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The five optional declaration surfaces, counted but never scored. */
|
|
282
|
+
export function declarationsOf(manifest) {
|
|
283
|
+
const dsh = manifest?.dsh ?? {}
|
|
284
|
+
const present = []
|
|
285
|
+
if (Array.isArray(dsh.capabilities) && dsh.capabilities.length > 0) present.push('capabilities')
|
|
286
|
+
if (Array.isArray(dsh.labels) && dsh.labels.length > 0) present.push('labels')
|
|
287
|
+
if (dsh.commitments && typeof dsh.commitments === 'object' && !Array.isArray(dsh.commitments)) present.push('commitments')
|
|
288
|
+
if (dsh.disclosure && typeof dsh.disclosure === 'object' && !Array.isArray(dsh.disclosure)) present.push('disclosure')
|
|
289
|
+
if (typeof manifest?.engines?.dsh === 'string' && manifest.engines.dsh.trim() !== '') present.push('engines.dsh')
|
|
290
|
+
return { total: 5, present }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function anchorOf(options, runtimeInfo, runtimePath) {
|
|
227
294
|
return {
|
|
228
295
|
repo: 'deepseek-ai/deepseek-harness',
|
|
229
296
|
commit: options.commit,
|
|
230
297
|
scanner: `dsh-plugin-verify@${VERSION}`,
|
|
231
|
-
dsh:
|
|
298
|
+
dsh: runtimeInfo.dsh,
|
|
232
299
|
cordis: options.cordisVersion,
|
|
233
|
-
runtime:
|
|
300
|
+
runtime: runtimePath ? path.resolve(runtimePath) : null,
|
|
301
|
+
runtimeSource: runtimeInfo.runtimeSource,
|
|
302
|
+
hostVersion: runtimeInfo.hostVersion,
|
|
303
|
+
suspect: runtimeInfo.suspect,
|
|
234
304
|
}
|
|
235
305
|
}
|
|
236
306
|
|
|
@@ -261,15 +331,23 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
261
331
|
return 3
|
|
262
332
|
}
|
|
263
333
|
|
|
334
|
+
const host = detectHostRuntime()
|
|
335
|
+
const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
|
|
336
|
+
|
|
264
337
|
const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
|
|
265
|
-
runtimeVersion:
|
|
338
|
+
runtimeVersion: runtimeInfo.dsh,
|
|
266
339
|
installSpec: options.installSpec,
|
|
267
340
|
marketIndex: options.marketIndex,
|
|
268
341
|
pluginName: options.pluginName,
|
|
269
342
|
})
|
|
270
343
|
|
|
271
344
|
let sources = []
|
|
272
|
-
|
|
345
|
+
let scan = null
|
|
346
|
+
try {
|
|
347
|
+
const read = readSourcesWithStats(dir, { maxFiles: options.maxFiles, maxBytes: options.maxBytes })
|
|
348
|
+
sources = read.sources
|
|
349
|
+
scan = read.scan
|
|
350
|
+
} catch { sources = [] }
|
|
273
351
|
const declarationChecks = manifest ? [
|
|
274
352
|
checkCapabilities(manifest, sources, patch),
|
|
275
353
|
checkCredentials(manifest, sources),
|
|
@@ -280,7 +358,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
280
358
|
{ id: 'DSC-13', clause: 'C-13', layer: 'L0', result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [], method: 'M', advisory: false },
|
|
281
359
|
]
|
|
282
360
|
const declarationAdjusted = declarationChecks.map(item => (item.result === 'WARN' && ['DSC-11', 'DSC-13'].includes(item.id) ? { ...item, advisory: true } : item))
|
|
283
|
-
const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative)]
|
|
361
|
+
const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative, scan)]
|
|
284
362
|
|
|
285
363
|
if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
|
|
286
364
|
let runtimeKind = kind
|
|
@@ -311,6 +389,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
311
389
|
})
|
|
312
390
|
}
|
|
313
391
|
|
|
392
|
+
// A bundle/client has no plugin entry for L1-L3. Instead of leaving all six
|
|
393
|
+
// runtime checks NA, cover their statically decidable part explicitly and
|
|
394
|
+
// mark the coverage partial (the report says what still needs --host/--runtime).
|
|
395
|
+
const runtimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
396
|
+
if (manifest && ['bundle', 'client'].includes(kind) && !runtimeUsable) {
|
|
397
|
+
const staticChecks = runStaticBundleChecks({ manifest, kind, entry, patch, sources, g4, scan, dir })
|
|
398
|
+
const staticById = new Map(staticChecks.map(item => [item.id, item]))
|
|
399
|
+
runtimeChecks = runtimeChecks.map(item => staticById.get(item.id) ?? item)
|
|
400
|
+
}
|
|
401
|
+
|
|
314
402
|
const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
|
|
315
403
|
const checks = [
|
|
316
404
|
manifestChecks[0], manifestChecks[1], manifestChecks[2],
|
|
@@ -341,7 +429,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
341
429
|
}
|
|
342
430
|
const report = buildReport({
|
|
343
431
|
subject,
|
|
344
|
-
anchor: anchorOf(options, options.runtime),
|
|
432
|
+
anchor: anchorOf(options, runtimeInfo, options.runtime),
|
|
345
433
|
checks,
|
|
346
434
|
g4,
|
|
347
435
|
scope: options.scope,
|
|
@@ -351,6 +439,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
351
439
|
entryRuntime: options.entryRuntime,
|
|
352
440
|
entryClaim: options.entryClaim,
|
|
353
441
|
advice: options.advice,
|
|
442
|
+
declarations: declarationsOf(manifest),
|
|
354
443
|
})
|
|
355
444
|
const output = writeReport(report, options.format)
|
|
356
445
|
if (options.out) {
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static G4 check: when `dsh.client` exists, the package must expose
|
|
3
|
-
* `exports["./client"]` and
|
|
3
|
+
* `exports["./client"]` and that target must exist in the package.
|
|
4
4
|
*
|
|
5
5
|
* This is a static shape check. It does not prove the client module loads or
|
|
6
6
|
* renders; that is a runtime/UI probe and is reported separately.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { isFile } from './util.mjs'
|
|
10
|
+
|
|
11
|
+
function clientTarget(exported) {
|
|
12
|
+
if (typeof exported === 'string') return exported
|
|
13
|
+
if (exported && typeof exported === 'object' && !Array.isArray(exported)) {
|
|
14
|
+
for (const key of ['default', 'import', 'require', 'node', 'browser']) {
|
|
15
|
+
if (typeof exported[key] === 'string') return exported[key]
|
|
16
|
+
}
|
|
17
|
+
for (const value of Object.values(exported)) {
|
|
18
|
+
const nested = clientTarget(value)
|
|
19
|
+
if (nested) return nested
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function checkClientShape(manifest, dir = null) {
|
|
9
26
|
const hasClient = manifest?.dsh?.client !== undefined
|
|
10
27
|
if (!hasClient) {
|
|
11
28
|
return {
|
|
@@ -28,10 +45,23 @@ export function checkClientShape(manifest) {
|
|
|
28
45
|
reason: 'dsh.client exists but exports["./client"] is missing; a package-root row cannot resolve it',
|
|
29
46
|
}
|
|
30
47
|
}
|
|
48
|
+
const target = clientTarget(exported)
|
|
49
|
+
if (target && typeof dir === 'string' && dir !== '' && !target.includes('*')) {
|
|
50
|
+
const absolute = path.resolve(dir, target)
|
|
51
|
+
const relative = path.relative(dir, absolute)
|
|
52
|
+
if (relative.startsWith('..') || path.isAbsolute(relative) || !isFile(absolute)) {
|
|
53
|
+
return {
|
|
54
|
+
hasClient: true,
|
|
55
|
+
ok: false,
|
|
56
|
+
result: 'FAIL',
|
|
57
|
+
reason: `exports["./client"] points to a missing file: ${target}`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
31
61
|
return {
|
|
32
62
|
hasClient: true,
|
|
33
63
|
ok: true,
|
|
34
64
|
result: 'PASS',
|
|
35
|
-
reason: 'dsh.client + exports["./client"] present (static only)',
|
|
65
|
+
reason: 'dsh.client + exports["./client"] present and resolvable (static only)',
|
|
36
66
|
}
|
|
37
67
|
}
|
|
@@ -424,13 +424,28 @@ function collectSideEffectHits(ast, file, hits) {
|
|
|
424
424
|
})
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
function analyseImportClosure(sources, entryRelative) {
|
|
427
|
+
function analyseImportClosure(sources, entryRelative, scan = null) {
|
|
428
428
|
const byRelative = new Map()
|
|
429
429
|
for (const source of sources) byRelative.set(normalizeRelative(source.relative), source)
|
|
430
430
|
const queue = []
|
|
431
431
|
if (entryRelative) {
|
|
432
432
|
const entry = normalizeRelative(entryRelative)
|
|
433
|
-
if (!byRelative.has(entry))
|
|
433
|
+
if (!byRelative.has(entry)) {
|
|
434
|
+
// A bounded scan can stop before the entry module. That is missing
|
|
435
|
+
// coverage, not proof that the package has no entry.
|
|
436
|
+
if (scan?.truncated) {
|
|
437
|
+
return {
|
|
438
|
+
entryOutsideScan: true,
|
|
439
|
+
truncated: scan,
|
|
440
|
+
visited: new Set(),
|
|
441
|
+
hits: [],
|
|
442
|
+
parseErrors: [],
|
|
443
|
+
coverage: [],
|
|
444
|
+
localEdges: [],
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return { error: `entry module ${entryRelative} was not found among scanned sources` }
|
|
448
|
+
}
|
|
434
449
|
queue.push(entry)
|
|
435
450
|
} else {
|
|
436
451
|
// Programmatic callers without an entry: keep the legacy "all sources" shape.
|
|
@@ -483,23 +498,30 @@ function hitEvidence(hits, limit = 3) {
|
|
|
483
498
|
return hits.slice(0, limit).map(hit => `${hit.file}:${hit.line}`)
|
|
484
499
|
}
|
|
485
500
|
|
|
501
|
+
/** The exact bounded-scan statement the report must carry when it truncates. */
|
|
502
|
+
export function scanTruncationReason(scan) {
|
|
503
|
+
if (!scan?.truncated) return ''
|
|
504
|
+
return `truncated at ${scan.maxFiles} files / ${scan.maxBytes} bytes; entry may be beyond the scan (scanned ${scan.files} files / ${scan.bytes} bytes)`
|
|
505
|
+
}
|
|
506
|
+
|
|
486
507
|
/** DSC-10: advisory only; never FAIL. */
|
|
487
|
-
export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
508
|
+
export function checkImportTimeAdvisory(sources, entryRelative = null, scan = null) {
|
|
488
509
|
if (sources.length === 0) {
|
|
510
|
+
const truncation = scanTruncationReason(scan)
|
|
489
511
|
return check({
|
|
490
512
|
id: 'DSC-10',
|
|
491
513
|
clause: 'C-10',
|
|
492
514
|
layer: 'L0',
|
|
493
515
|
result: 'NA',
|
|
494
516
|
advisory: true,
|
|
495
|
-
coverage: 'not-run',
|
|
496
|
-
reason: 'no source files scanned; C-10 not-run',
|
|
497
|
-
details: [],
|
|
517
|
+
coverage: truncation ? 'partial' : 'not-run',
|
|
518
|
+
reason: truncation ? `partial: ${truncation}; C-10 not-run` : 'no source files scanned; C-10 not-run',
|
|
519
|
+
details: truncation ? [detail('C-10.3', 'NA', `not-run: ${truncation}`, [])] : [],
|
|
498
520
|
evidence: [],
|
|
499
521
|
})
|
|
500
522
|
}
|
|
501
523
|
|
|
502
|
-
const analysis = analyseImportClosure(sources, entryRelative)
|
|
524
|
+
const analysis = analyseImportClosure(sources, entryRelative, scan)
|
|
503
525
|
if (analysis.error) {
|
|
504
526
|
const evidence = normalizeRelative(entryRelative)
|
|
505
527
|
return check({
|
|
@@ -515,12 +537,32 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
|
515
537
|
})
|
|
516
538
|
}
|
|
517
539
|
|
|
540
|
+
if (analysis.entryOutsideScan) {
|
|
541
|
+
const truncation = scanTruncationReason(analysis.truncated)
|
|
542
|
+
const entry = normalizeRelative(entryRelative)
|
|
543
|
+
return check({
|
|
544
|
+
id: 'DSC-10',
|
|
545
|
+
clause: 'C-10',
|
|
546
|
+
layer: 'L0',
|
|
547
|
+
result: 'NA',
|
|
548
|
+
advisory: true,
|
|
549
|
+
coverage: 'partial',
|
|
550
|
+
reason: `partial: ${truncation}; C-10 not-run`,
|
|
551
|
+
details: [detail('C-10.3', 'NA', `not-run: ${truncation}; entry module ${entryRelative} is outside the scanned set`, [entry])],
|
|
552
|
+
evidence: [entry],
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const truncation = scanTruncationReason(scan)
|
|
518
557
|
const { visited, hits, parseErrors, coverage, localEdges } = analysis
|
|
519
558
|
const details = hits.slice(0, 20).map(hit => detail(hit.clause, 'WARN', `${hit.pattern} at ${hit.file}:${hit.line}`, [`${hit.file}:${hit.line}`]))
|
|
520
559
|
details.push(...coverage.slice(0, 10))
|
|
521
560
|
if (localEdges.length > 0) {
|
|
522
561
|
details.push(detail('C-10.3', 'PASS', `local relative import closure analysed (${visited.size} module(s))`, [...visited].slice(0, 5)))
|
|
523
562
|
}
|
|
563
|
+
if (truncation) {
|
|
564
|
+
details.push(detail('C-10.3', 'NA', `not-run: ${truncation}; the local import closure is incomplete`, []))
|
|
565
|
+
}
|
|
524
566
|
for (const error of parseErrors) {
|
|
525
567
|
details.push(detail('C-10', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
|
|
526
568
|
}
|
|
@@ -538,6 +580,9 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
|
538
580
|
result = 'PASS'
|
|
539
581
|
reason = ''
|
|
540
582
|
}
|
|
583
|
+
if (truncation) {
|
|
584
|
+
reason = reason ? `${reason}; partial: ${truncation}` : `partial: ${truncation}`
|
|
585
|
+
}
|
|
541
586
|
|
|
542
587
|
const evidence = hits.length > 0
|
|
543
588
|
? hitEvidence(hits)
|
|
@@ -551,7 +596,7 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
|
551
596
|
layer: 'L0',
|
|
552
597
|
result,
|
|
553
598
|
advisory: true,
|
|
554
|
-
coverage: result === 'ERROR' ? 'not-run' : 'full',
|
|
599
|
+
coverage: result === 'ERROR' ? 'not-run' : truncation ? 'partial' : 'full',
|
|
555
600
|
reason,
|
|
556
601
|
details,
|
|
557
602
|
evidence,
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import crypto from 'node:crypto'
|
|
27
27
|
import fs from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
28
29
|
import path from 'node:path'
|
|
30
|
+
import { spawnSync } from 'node:child_process'
|
|
29
31
|
|
|
30
32
|
export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
|
|
31
33
|
export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
|
|
@@ -144,6 +146,103 @@ export function treeSha256(dir, { exclude = [] } = {}) {
|
|
|
144
146
|
return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
150
|
+
|
|
151
|
+
function subjectFields(subject) {
|
|
152
|
+
if (!isPlainObject(subject)) return null
|
|
153
|
+
return {
|
|
154
|
+
name: subject.name ?? null,
|
|
155
|
+
version: subject.version ?? null,
|
|
156
|
+
integrity: subject.integrity ?? null,
|
|
157
|
+
tarballSha256: subject.tarballSha256 ?? null,
|
|
158
|
+
treeSha256: subject.treeSha256 ?? null,
|
|
159
|
+
commit: subject.commit ?? null,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Recompute the subject binding from a package directory: package.json
|
|
165
|
+
* name/version, the deterministic tree hash, and the npm-pack tarball hash +
|
|
166
|
+
* integrity. npm packs the same tree byte-identically, so this is the same
|
|
167
|
+
* binding `dsh-flow-collect` recorded.
|
|
168
|
+
*/
|
|
169
|
+
export function recomputeSubject(dir, { exclude = [], timeoutMs = 120_000 } = {}) {
|
|
170
|
+
const root = path.resolve(dir)
|
|
171
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
172
|
+
const tree = treeSha256(root, { exclude })
|
|
173
|
+
const destination = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-subject-'))
|
|
174
|
+
try {
|
|
175
|
+
const run = spawnSync(NPM, ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], {
|
|
176
|
+
cwd: root, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024,
|
|
177
|
+
})
|
|
178
|
+
if (run.error) throw new Error(`npm pack failed to start: ${run.error.message}`)
|
|
179
|
+
if (run.status !== 0) throw new Error(`npm pack failed (exit ${run.status}): ${String(run.stderr ?? '').replace(/\s+/g, ' ').slice(0, 300)}`)
|
|
180
|
+
let parsed
|
|
181
|
+
try { parsed = JSON.parse(run.stdout) } catch { throw new Error('npm pack did not print JSON') }
|
|
182
|
+
const info = Array.isArray(parsed) ? parsed[0] : parsed
|
|
183
|
+
if (!info?.filename) throw new Error('npm pack did not report a tarball filename')
|
|
184
|
+
return {
|
|
185
|
+
name: typeof manifest.name === 'string' ? manifest.name : null,
|
|
186
|
+
version: typeof manifest.version === 'string' ? manifest.version : null,
|
|
187
|
+
treeSha256: tree.hash,
|
|
188
|
+
tarballSha256: sha256File(path.join(destination, info.filename)),
|
|
189
|
+
integrity: typeof info.integrity === 'string' ? info.integrity : null,
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(destination, { recursive: true, force: true })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bind a flow-evidence document to a package directory. Any mismatch in
|
|
198
|
+
* name/version/treeSha256/tarballSha256 (or a declared integrity) is a hard
|
|
199
|
+
* failure: the audit must not accept evidence for a different subject.
|
|
200
|
+
*/
|
|
201
|
+
export function bindEvidenceSubject(document, dir, options = {}) {
|
|
202
|
+
const declared = isPlainObject(document?.subject) ? document.subject : null
|
|
203
|
+
const base = { dir: path.resolve(dir), declared: subjectFields(declared), recomputed: null, mismatches: [], errors: [] }
|
|
204
|
+
if (!declared) return { ...base, ok: false, errors: ['subject is missing'] }
|
|
205
|
+
let recomputed
|
|
206
|
+
try {
|
|
207
|
+
recomputed = recomputeSubject(dir, {
|
|
208
|
+
exclude: Array.isArray(declared.treeExclude) ? declared.treeExclude : [],
|
|
209
|
+
timeoutMs: options.timeoutMs,
|
|
210
|
+
})
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return { ...base, ok: false, errors: [error.message] }
|
|
213
|
+
}
|
|
214
|
+
const mismatches = []
|
|
215
|
+
for (const key of ['name', 'version', 'treeSha256', 'tarballSha256']) {
|
|
216
|
+
if (declared[key] !== recomputed[key]) {
|
|
217
|
+
mismatches.push(`${key}: declared ${declared[key] ?? 'null'} != recomputed ${recomputed[key] ?? 'null'}`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (typeof declared.integrity === 'string' && recomputed.integrity !== null && declared.integrity !== recomputed.integrity) {
|
|
221
|
+
mismatches.push('integrity: declared value != recomputed value')
|
|
222
|
+
}
|
|
223
|
+
return { ...base, ok: mismatches.length === 0, recomputed, mismatches }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Plain CI attribution for display. These fields are read from the runner
|
|
228
|
+
* environment and are self-declared: CI attribution is not proof.
|
|
229
|
+
*/
|
|
230
|
+
export function ciAttribution(env = process.env) {
|
|
231
|
+
const read = key => (typeof env?.[key] === 'string' ? env[key].trim() : '')
|
|
232
|
+
const runId = read('GITHUB_RUN_ID')
|
|
233
|
+
const repository = read('GITHUB_REPOSITORY')
|
|
234
|
+
const commit = read('GITHUB_SHA')
|
|
235
|
+
if (!runId && !repository && !commit) return null
|
|
236
|
+
const server = (read('GITHUB_SERVER_URL') || 'https://github.com').replace(/\/+$/, '')
|
|
237
|
+
return {
|
|
238
|
+
provider: 'github-actions',
|
|
239
|
+
runId: runId || null,
|
|
240
|
+
repository: repository || null,
|
|
241
|
+
commit: commit || null,
|
|
242
|
+
runUrl: runId && repository ? `${server}/${repository}/actions/runs/${runId}` : null,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
147
246
|
export function isIsoTimestamp(value) {
|
|
148
247
|
return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
|
|
149
248
|
}
|
|
@@ -279,9 +378,15 @@ function validateV2(document) {
|
|
|
279
378
|
if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox', 'sandbox-degraded'].includes(document.producer.kind)) {
|
|
280
379
|
errors.push('producer.kind must be local, ci, sandbox, or sandbox-degraded')
|
|
281
380
|
}
|
|
282
|
-
for (const key of ['runner', 'actor', 'runUrl']) {
|
|
381
|
+
for (const key of ['runner', 'actor', 'runUrl', 'provider', 'runId', 'repository', 'commit']) {
|
|
283
382
|
if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
|
|
284
383
|
}
|
|
384
|
+
if (typeof document.producer.commit === 'string' && !COMMIT_RE.test(document.producer.commit)) {
|
|
385
|
+
errors.push('producer.commit must be a 40-hex commit')
|
|
386
|
+
}
|
|
387
|
+
if (typeof document.producer.runUrl === 'string' && !/^https:\/\//.test(document.producer.runUrl)) {
|
|
388
|
+
errors.push('producer.runUrl must be an https URL')
|
|
389
|
+
}
|
|
285
390
|
}
|
|
286
391
|
|
|
287
392
|
if (requireObject(document.subject, 'subject')) {
|
package/40-checker/src/index.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/** Programmatic API for dsh-plugin-verify. */
|
|
2
|
-
export { main, parseArgs, usage, VERSION } from './cli.mjs'
|
|
2
|
+
export { main, parseArgs, usage, VERSION, detectHostRuntime, resolveRuntimeAnchor, declarationsOf } from './cli.mjs'
|
|
3
3
|
export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind, resolveEntry } from './manifest.mjs'
|
|
4
|
-
export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
|
|
4
|
+
export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, scanTruncationReason, CAPABILITIES, LABELS } from './declarations.mjs'
|
|
5
|
+
export { readSources, readSourcesWithStats, listSourceFiles, scanSourceFiles } from './util.mjs'
|
|
5
6
|
export { runRuntimeChecks } from './runtime.mjs'
|
|
6
|
-
export {
|
|
7
|
+
export { runStaticBundleChecks } from './bundle-static.mjs'
|
|
8
|
+
export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, DECLARATION_CHECKS } from './report.mjs'
|
|
7
9
|
export { joinAdvice, DOCS_BASE } from './remediation.mjs'
|
|
8
10
|
export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
|
|
9
11
|
export { parseInstallSpec } from './install-spec.mjs'
|
|
10
|
-
export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
|
|
12
|
+
export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence, recomputeSubject, bindEvidenceSubject, ciAttribution } from './flow-evidence.mjs'
|
|
11
13
|
export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'
|