dsh-standards 1.5.0 → 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 (63) 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/src/cli.mjs +8 -3
  51. package/40-checker/src/declarations.mjs +10 -10
  52. package/40-checker/src/index.mjs +2 -1
  53. package/40-checker/src/report.mjs +68 -2
  54. package/40-checker/src/security.mjs +1065 -0
  55. package/40-checker/src/util.mjs +53 -0
  56. package/CHANGELOG.md +14 -0
  57. package/docs/SIGNING.md +137 -0
  58. package/docs/coverage.md +39 -6
  59. package/package.json +1 -1
  60. package/spec/coverage-contract.md +33 -11
  61. package/spec/dsc-clauses.json +410 -9
  62. package/spec/remediation.json +196 -0
  63. package/spec/schemas/conformance.schema.json +75 -2
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
6
6
  import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
7
+ import { runSecurityChecks } from './security.mjs'
7
8
  import { runRuntimeChecks } from './runtime.mjs'
8
9
  import { runStaticBundleChecks } from './bundle-static.mjs'
9
10
  import { spawn, spawnSync } from 'node:child_process'
@@ -32,7 +33,8 @@ Usage:
32
33
 
33
34
  Options:
34
35
  --format text|json|sarif output format (default: text)
35
- --scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
36
+ --scope mvp|ds-c|entry|security mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate;
37
+ security = DSC-15/16/17/21 are blocking, DSC-18/19/20/22 advisory
36
38
  --profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
37
39
  --runtime [path|auto] built @deepseek-ai/cordis lib/index.js (or its package dir); bare or
38
40
  'auto' resolves from the installed dsh; enables real L1-L3
@@ -161,10 +163,10 @@ function parseArgs(argv) {
161
163
  }
162
164
  }
163
165
  if (!['text', 'json', 'sarif'].includes(options.format)) throw new Error(`unsupported --format: ${options.format}`)
164
- if (!['mvp', 'ds-c', 'entry'].includes(options.scope)) throw new Error(`unsupported --scope: ${options.scope}`)
166
+ if (!['mvp', 'ds-c', 'entry', 'security'].includes(options.scope)) throw new Error(`unsupported --scope: ${options.scope}`)
165
167
  if (!['require', 'server-entry'].includes(options.entryRuntime)) throw new Error('--entry-runtime must be require or server-entry')
166
168
  if (!['runtime', 'static'].includes(options.entryClaim)) throw new Error('--entry-claim must be runtime or static')
167
- if (options.staticOnly) { options.scope = 'ds-c'; options.profile = 'listing' }
169
+ if (options.staticOnly) { if (options.scope !== 'security') options.scope = 'ds-c'; options.profile = 'listing' }
168
170
  if (!options.profile) options.profile = options.marketIndex ? 'listing' : 'package'
169
171
  if (!['package', 'listing', 'both'].includes(options.profile)) throw new Error(`unsupported --profile: ${options.profile}`)
170
172
  if (options.staticOnly && options.treatAsPlugin) throw new Error('--static-only and --treat-as-plugin are mutually exclusive')
@@ -602,6 +604,7 @@ export async function main(argv = process.argv.slice(2)) {
602
604
  ]
603
605
  const declarationAdjusted = declarationChecks.map(item => (item.result === 'WARN' && ['DSC-11', 'DSC-13'].includes(item.id) ? { ...item, advisory: true } : item))
604
606
  const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative, scan)]
607
+ const securityChecks = runSecurityChecks({ dir, manifest, sources, entry, kind })
605
608
 
606
609
  if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
607
610
  let runtimeKind = kind
@@ -721,6 +724,7 @@ export async function main(argv = process.argv.slice(2)) {
721
724
  runCheck,
722
725
  advisoryChecks[0],
723
726
  ...declarationAdjusted,
727
+ ...(options.scope === 'security' ? securityChecks : []),
724
728
  ]
725
729
 
726
730
  if (g4?.result === 'FAIL') {
@@ -756,6 +760,7 @@ export async function main(argv = process.argv.slice(2)) {
756
760
  advice: options.advice,
757
761
  declarations: declarationsOf(manifest),
758
762
  runtimeChecks: runtimeSummary,
763
+ securityChecks,
759
764
  })
760
765
  // An explicitly requested runtime/host run that could not produce a result
761
766
  // is not a clean static pass: the caller asked for execution, so say the run
@@ -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') {
@@ -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, runtimeChecks = 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
@@ -161,7 +221,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
161
221
  } else if (status === 'pass' || status === 'warn') {
162
222
  level = `DSH-ENTRY static${status === 'warn' ? ' (warnings)' : ''}`
163
223
  }
164
- } else if (scope !== 'mvp' && (status === 'pass' || status === 'warn')) {
224
+ } else if (scope !== 'mvp' && scope !== 'security' && (status === 'pass' || status === 'warn')) {
165
225
  const suffix = status === 'warn' ? ', warnings' : ''
166
226
  level = staticOnly ? `DS-C static${status === 'warn' ? ' (warnings)' : ''}` : profile === 'listing' || profile === 'both' ? `DS-C (listing${suffix})` : `DS-C (package${suffix})`
167
227
  }
@@ -181,6 +241,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
181
241
  const hygieneBucket = effectiveChecks.filter(item => item.result === 'WARN').map(item => bucketRow(item, {
182
242
  declaration: DECLARATION_CHECKS.has(item.id),
183
243
  advisory: item.advisory === true,
244
+ ...(SECURITY_CHECKS.has(item.id) ? { security: true } : {}),
184
245
  }))
185
246
  const notCheckedBucket = effectiveChecks
186
247
  .filter(item => item.result === 'NA' || item.result === 'ERROR')
@@ -240,6 +301,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
240
301
  entryBlocking,
241
302
  },
242
303
  buckets,
304
+ security,
243
305
  notChecked,
244
306
  metrics,
245
307
  conclusion: {
@@ -369,6 +431,10 @@ export function formatText(report) {
369
431
  const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
370
432
  lines.push(`passed (${passed.length}): ${passed.map(item => item.id).join(', ') || 'none'}`)
371
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
+ }
372
438
  if (coverage.entryBlocking) {
373
439
  const blocking = coverage.entryBlocking
374
440
  lines.push(`entryBlocking: checked=${blocking.checked?.length ?? '?'}/${blocking.required} failed=[${(blocking.failed ?? []).join(', ')}] notChecked=[${(blocking.notChecked ?? []).join(', ')}] pass=${blocking.pass}`)