@goodandready/dsh-model-sync 0.5.9 → 0.5.10

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/lib/client.js CHANGED
@@ -600,6 +600,7 @@ window.__ModuleLoader__.load({
600
600
  setBusy(true); setWebhookErr(''); setWebhookMsg('')
601
601
  try {
602
602
  await scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || '')
603
+ await scope.set('diffWebhookSecretRef', webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '')
603
604
  await scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || '')
604
605
  await scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded)
605
606
  await scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated)
@@ -622,6 +623,7 @@ window.__ModuleLoader__.load({
622
623
  body: JSON.stringify({
623
624
  url: webhookDraft.diffWebhookUrl,
624
625
  secret: webhookDraft.diffWebhookSecret || '',
626
+ secretRef: webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '',
625
627
  }),
626
628
  })
627
629
  const body = await res.json()
@@ -38,9 +38,18 @@ function safeCode(error) {
38
38
  return 'credential-error'
39
39
  }
40
40
 
41
+ export function sanitizeSecretString(value) {
42
+ if (value === null || value === undefined) return ''
43
+ return String(value)
44
+ .replace(/(authorization)\s*[:=]\s*(?:bearer|basic|token)\s+[^\s,;&]+/gi, '$1=[redacted]')
45
+ .replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;&]+/gi, '$1=[redacted]')
46
+ .replace(/\b(bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, '$1[redacted]')
47
+ .replace(/([?&](?:key|token|api[_-]?key|access[_-]?token|secret)=)[^&#\s]+/gi, '$1[redacted]')
48
+ }
49
+
41
50
  function safeMessage(error) {
42
51
  const source = error instanceof Error ? error.message : String(error ?? '')
43
- return source.replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
52
+ return sanitizeSecretString(source).slice(0, 160)
44
53
  }
45
54
 
46
55
  export function createCredentialResolver(ctx) {
@@ -104,6 +104,43 @@ export function extractDiffEvents(results, options = {}) {
104
104
  }
105
105
  }
106
106
 
107
+ export function isLoopbackIPv4(address) {
108
+ if (typeof address !== 'string' || !address.startsWith('127.')) return false
109
+ const parts = address.split('.')
110
+ if (parts.length !== 4) return false
111
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
112
+ }
113
+
114
+ export function isForbiddenWebhookHost(rawHostname) {
115
+ const host = String(rawHostname || '').toLowerCase().replace(/^\[|\]$/g, '')
116
+ if (host === '169.254.169.254' || host.startsWith('169.254.')) return true
117
+ if (host.startsWith('::ffff:')) {
118
+ const v4 = host.slice('::ffff:'.length)
119
+ if (v4.startsWith('169.254.') || v4.startsWith('a9fe:')) return true
120
+ if (v4 === '100.100.100.200' || v4 === '6464:64c8') return true
121
+ }
122
+ if (host.startsWith('fe80:') || host.startsWith('fe80::')) return true
123
+ if (host === '100.100.100.200') return true
124
+ if (host === '0.0.0.0' || host === '255.255.255.255' || host === '::' || host === '0:0:0:0:0:0:0:0') return true
125
+ if (
126
+ host === 'metadata.google.internal' ||
127
+ host === 'instance-data' ||
128
+ host === 'metadata' ||
129
+ host.endsWith('.metadata.google.internal') ||
130
+ host.endsWith('.metadata')
131
+ ) return true
132
+ return false
133
+ }
134
+
135
+ export function isLoopbackHost(rawHostname) {
136
+ const host = String(rawHostname || '').toLowerCase().replace(/^\[|\]$/g, '')
137
+ if (host === '127.0.0.1' || host === 'localhost' || host === 'localhost.' || host === '::1') return true
138
+ if (host.startsWith('::ffff:')) {
139
+ return isLoopbackIPv4(host.slice('::ffff:'.length))
140
+ }
141
+ return isLoopbackIPv4(host)
142
+ }
143
+
107
144
  export async function sendWebhookNotification(url, secret, payload, { fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
108
145
  if (typeof url !== "string" || !url.trim()) {
109
146
  return { ok: false, error: "URL is required" }
@@ -115,16 +152,10 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
115
152
  return { ok: false, error: "URL must use http or https protocol" }
116
153
  }
117
154
  const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '')
118
- if (
119
- hostname === '169.254.169.254' ||
120
- hostname.startsWith('169.254.') ||
121
- hostname === 'metadata.google.internal' ||
122
- hostname === '0.0.0.0' ||
123
- hostname === '255.255.255.255'
124
- ) {
155
+ if (isForbiddenWebhookHost(hostname)) {
125
156
  return { ok: false, error: "webhook url targets a forbidden metadata or link-local address" }
126
157
  }
127
- const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
158
+ const isLoopback = isLoopbackHost(hostname)
128
159
  if (parsedUrl.protocol === "http:" && !isLoopback) {
129
160
  return { ok: false, error: "insecure http webhook url is only permitted on loopback" }
130
161
  }
@@ -195,17 +226,37 @@ export async function dispatchDiffNotifications(ctx, config, results, { fetchImp
195
226
 
196
227
  let webhookResult = null
197
228
  if (config?.diffWebhookUrl && typeof config.diffWebhookUrl === "string" && config.diffWebhookUrl.trim()) {
198
- let secret = config.diffWebhookSecret || ''
199
- const secretRef = config.diffWebhookSecretRef || (typeof config.diffWebhookSecret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(config.diffWebhookSecret) ? config.diffWebhookSecret : '')
229
+ let secret = ''
230
+ const secretRef = (config.diffWebhookSecretRef || '').trim()
200
231
  if (secretRef) {
232
+ let resolvedSecret = ''
201
233
  try {
202
- const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secretRef) : ctx?.get?.('credentials')?.resolve?.(secretRef))
203
- if (res) secret = typeof res === 'string' ? res : res?.value || secret
234
+ const creds = ctx?.credentials || ctx?.get?.('credentials')
235
+ if (creds?.resolve) {
236
+ const res = await creds.resolve(secretRef)
237
+ resolvedSecret = typeof res === 'string' ? res : res?.value || ''
238
+ }
204
239
  } catch (err) {
205
- if (typeof ctx?.logger?.debug === 'function') {
206
- ctx.logger.debug('[dsh-model-sync] webhook secret resolution fallback:', err)
240
+ if (typeof ctx?.logger?.warn === 'function') {
241
+ ctx.logger.warn('[dsh-model-sync] webhook secret resolution failed:', err)
242
+ }
243
+ }
244
+ if (!resolvedSecret) {
245
+ // FAIL-CLOSED: do not dispatch unsigned webhook when secret was expected (#204)
246
+ if (typeof ctx?.logger?.warn === 'function') {
247
+ ctx.logger.warn(`[dsh-model-sync] webhook secret resolution failed for '${secretRef}', skipping webhook dispatch (fail-closed)`)
248
+ }
249
+ return {
250
+ ok: false,
251
+ error: 'credential_resolution_failed',
252
+ message: `Failed to resolve webhook secret credential reference '${secretRef}'`,
253
+ events,
254
+ dispatched: false,
207
255
  }
208
256
  }
257
+ secret = resolvedSecret
258
+ } else if (config.diffWebhookSecret) {
259
+ secret = String(config.diffWebhookSecret).trim()
209
260
  }
210
261
  webhookResult = await sendWebhookNotification(
211
262
  config.diffWebhookUrl,
package/lib/history.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeSecretString } from './credentials.js'
1
2
  import { normalizeModels } from './models.js'
2
3
 
3
4
  export const DEFAULT_HISTORY_LIMIT = 50
@@ -41,7 +42,7 @@ function catalog(provider, models) {
41
42
 
42
43
  function message(value) {
43
44
  if (value === undefined || value === null) return undefined
44
- return String(value).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, MAX_MESSAGE_LENGTH)
45
+ return sanitizeSecretString(value).slice(0, MAX_MESSAGE_LENGTH)
45
46
  }
46
47
 
47
48
  function diffFor(provider, diff) {
package/lib/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isForbiddenWebhookHost, isLoopbackHost } from './diff-notifier.js'
1
2
  import crypto from 'node:crypto'
2
3
  import { validateAliasName } from './alias-resolver.js'
3
4
  import { validatePolicy } from './policy.js'
@@ -15,22 +16,17 @@ function normalizeWebhookTestRequest(body) {
15
16
  throw Object.assign(new Error("URL must use http or https protocol"), { code: "INVALID_REQUEST" })
16
17
  }
17
18
  const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
18
- const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
19
+ if (isForbiddenWebhookHost(hostname)) {
20
+ throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
21
+ }
22
+ const isLoopback = isLoopbackHost(hostname)
19
23
  if (parsed.protocol === "http:" && !isLoopback) {
20
24
  throw Object.assign(new Error("insecure http webhook url is only permitted on loopback"), { code: "INVALID_REQUEST" })
21
25
  }
22
- if (
23
- hostname === '169.254.169.254' ||
24
- hostname.startsWith('169.254.') ||
25
- hostname === 'metadata.google.internal' ||
26
- hostname === '0.0.0.0' ||
27
- hostname === '255.255.255.255'
28
- ) {
29
- throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
30
- }
31
26
  return {
32
27
  url: parsed.toString(),
33
28
  secret: typeof body.secret === "string" ? body.secret.trim() : "",
29
+ ...(body.secretRef && typeof body.secretRef === "string" ? { secretRef: body.secretRef.trim() } : {}),
34
30
  }
35
31
  }
36
32
 
@@ -42,14 +38,17 @@ function json(res, status, body) {
42
38
  }
43
39
 
44
40
  export function isLoopbackAddress(ip) {
45
- if (!ip || typeof ip !== 'string') return false
46
- const clean = ip.toLowerCase().replace(/^\[|\]$/g, '')
47
- if (clean === '127.0.0.1' || clean === '::1' || clean === '::ffff:127.0.0.1' || clean === 'localhost') return true
48
- if (clean.startsWith('127.') || clean.startsWith('::ffff:127.')) return true
49
- return false
41
+ return isLoopbackHost(ip)
50
42
  }
51
43
 
52
- export function trusted(req, { requireLoopback = false } = {}) {
44
+ export function trusted(req, { requireLoopback = false, ctx = null } = {}) {
45
+ // 1. Core connection rejection check (#237)
46
+ const conn = ctx?.connection || ctx?.get?.('connection') || req?.connection
47
+ if (conn && typeof conn.requestRejection === 'function') {
48
+ const rejection = conn.requestRejection(req)
49
+ if (rejection !== undefined) return false
50
+ }
51
+
53
52
  const remoteAddress = req?.socket?.remoteAddress
54
53
  if (requireLoopback || req?.method === 'POST') {
55
54
  if (remoteAddress && !isLoopbackAddress(remoteAddress)) return false
@@ -66,7 +65,7 @@ export function trusted(req, { requireLoopback = false } = {}) {
66
65
  const url = new URL(origin)
67
66
  if (url.host !== host) return false
68
67
  if (requireLoopback || req?.method === 'POST') {
69
- if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
68
+ if (!isLoopbackAddress(url.hostname)) return false
70
69
  }
71
70
  } catch { return false }
72
71
  }
@@ -77,7 +76,7 @@ export function trusted(req, { requireLoopback = false } = {}) {
77
76
  const url = new URL(referer)
78
77
  if (url.host !== host) return false
79
78
  if (requireLoopback || req?.method === 'POST') {
80
- if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
79
+ if (!isLoopbackAddress(url.hostname)) return false
81
80
  }
82
81
  } catch { return false }
83
82
  }
@@ -273,8 +272,18 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
273
272
  return []
274
273
  }
275
274
  const makeHandler = (route) => async (req, res) => {
275
+ // 1. Core connection check on all routes (#237)
276
+ const conn = ctx?.connection || ctx?.get?.('connection') || req?.connection
277
+ if (conn && typeof conn.requestRejection === 'function') {
278
+ const rejection = conn.requestRejection(req)
279
+ if (rejection !== undefined) {
280
+ const statusCode = typeof rejection === 'number' ? rejection : 401
281
+ return json(res, statusCode, { error: { code: 'unauthorized', message: 'dsh-model-sync: core connection rejected request' } })
282
+ }
283
+ }
284
+
276
285
  const isPublicStatus = route === 'status' && req.method === 'GET'
277
- if (!trusted(req, { requireLoopback: !isPublicStatus })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
286
+ if (!trusted(req, { requireLoopback: !isPublicStatus, ctx })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
278
287
  if (route === 'status') {
279
288
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
280
289
  const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
package/lib/policy.js CHANGED
@@ -41,6 +41,12 @@ function isDangerousPattern(pattern) {
41
41
  if (/\([^\)]*([+*]|\{[0-9]+,[0-9]*\})[^\)]*\)([+*]|\{[0-9]+,[0-9]*\})/.test(pattern)) return true
42
42
  // Check for multiple consecutive open-ended wildcards (e.g. .*.*)
43
43
  if (/(\.\*|\.\+){2,}/.test(pattern)) return true
44
+ // Check for quantified alternation groups like (a|aa)+, (a|b)+, (a|b)*, (a|aa){2,}
45
+ let simplified = pattern
46
+ while (/\(\(([^()]+)\)\)/.test(simplified)) {
47
+ simplified = simplified.replace(/\(\(([^()]+)\)\)/g, '($1)')
48
+ }
49
+ if (/\((?:[^()]*\|)+[^()]*\)\s*([+*]|\{[0-9]+,[0-9]*\})/.test(simplified)) return true
44
50
  return false
45
51
  }
46
52
 
package/lib/reporting.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeSecretString } from './credentials.js'
1
2
  import { createHash } from 'node:crypto'
2
3
 
3
4
  export const DEFAULT_NOTIFICATION_LIMIT = 50
@@ -14,10 +15,7 @@ export function notificationLimit(value) {
14
15
  }
15
16
 
16
17
  function safeMessage(value) {
17
- return String(value ?? '')
18
- .replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
19
- .replace(/([?&](?:key|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi, '$1[redacted]')
20
- .slice(0, 240)
18
+ return sanitizeSecretString(value).slice(0, 240)
21
19
  }
22
20
 
23
21
  function stable(value) {
@@ -1,3 +1,4 @@
1
+ import { sanitizeSecretString } from './credentials.js'
1
2
  import { statusOfError } from './reliability.js'
2
3
  import { historyEntry } from './history.js'
3
4
  import { normalizeModels } from './models.js'
@@ -89,7 +90,7 @@ export function errorMessage(error) {
89
90
  }
90
91
 
91
92
  export function diagnosticMessage(error) {
92
- return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
93
+ return sanitizeSecretString(errorMessage(error)).slice(0, 160)
93
94
  }
94
95
 
95
96
  export function adapterErrorType(error) {
@@ -571,14 +571,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
571
571
  notifications: notificationLedger,
572
572
  updateNotification: updateNotificationState,
573
573
  rollbackHistory,
574
- testWebhook: async ({ url, secret }) => {
574
+ testWebhook: async ({ url, secret, secretRef }) => {
575
575
  let resolvedSecret = secret || ''
576
- if (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret)) {
576
+ const targetRef = (secretRef || (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret) ? secret : '')).trim()
577
+ if (targetRef) {
577
578
  try {
578
- const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secret) : ctx?.get?.('credentials')?.resolve?.(secret))
579
- if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || resolvedSecret
579
+ const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(targetRef) : ctx?.get?.('credentials')?.resolve?.(targetRef))
580
+ if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || ''
580
581
  } catch (err) {
581
- ctx?.logger?.debug?.('[dsh-model-sync] secret ref resolution fallback:', err)
582
+ ctx?.logger?.warn?.('[dsh-model-sync] secret ref resolution failed in testWebhook:', err)
583
+ }
584
+ if (!resolvedSecret && secretRef) {
585
+ return { ok: false, error: `Failed to resolve webhook secret credential reference '${secretRef}'` }
582
586
  }
583
587
  }
584
588
  return sendWebhookNotification(url, resolvedSecret, { event: 'model-sync.test', timestamp: Date.now(), test: true })
package/lib/updater.js CHANGED
@@ -20,19 +20,33 @@ function header(request, name) {
20
20
  return Array.isArray(value) ? value[0] : value
21
21
  }
22
22
 
23
+ function isLoopbackIPv4(address) {
24
+ if (typeof address !== 'string' || !address.startsWith('127.')) return false
25
+ const parts = address.split('.')
26
+ if (parts.length !== 4) return false
27
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
28
+ }
29
+
23
30
  export function isLoopback(value) {
24
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
- return (
26
- address === 'localhost' ||
27
- address === 'localhost.' ||
28
- address === '::1' ||
29
- address?.startsWith('127.') === true ||
30
- address?.startsWith('::ffff:127.') === true
31
- )
31
+ if (typeof value !== 'string') return false
32
+ const address = value.toLowerCase().replace(/^\[|\]$/g, '')
33
+ if (address === 'localhost' || address === 'localhost.' || address === '::1') return true
34
+ if (address.startsWith('::ffff:')) {
35
+ return isLoopbackIPv4(address.slice('::ffff:'.length))
36
+ }
37
+ return isLoopbackIPv4(address)
32
38
  }
33
39
 
34
- export function isTrustedUpdateRequest(request) {
40
+ export function isTrustedUpdateRequest(request, targetCtx = null) {
35
41
  if (header(request, UPDATE_HEADER) !== '1') return false
42
+
43
+ // 1. Core connection rejection check (#237)
44
+ const conn = targetCtx?.connection || targetCtx?.get?.('connection') || request?.connection
45
+ if (conn && typeof conn.requestRejection === 'function') {
46
+ const rejection = conn.requestRejection(request)
47
+ if (rejection !== undefined) return false
48
+ }
49
+
36
50
  const remote = request.socket?.remoteAddress
37
51
  if (!isLoopback(remote)) return false
38
52
  const site = header(request, 'sec-fetch-site')
@@ -295,6 +309,21 @@ export function registerPluginUpdater(ctx, options = {}) {
295
309
 
296
310
  const handler = async (request, response) => {
297
311
  try {
312
+ // 1. Core connection rejection check on all updater routes (#237)
313
+ const conn = ctx?.connection || ctx?.get?.('connection') || request?.connection
314
+ if (conn && typeof conn.requestRejection === 'function') {
315
+ const rejection = conn.requestRejection(request)
316
+ if (rejection !== undefined) {
317
+ const status = typeof rejection === 'number' ? rejection : 401
318
+ response.writeHead(status, {
319
+ 'content-type': 'application/json; charset=utf-8',
320
+ 'cache-control': 'no-store',
321
+ })
322
+ response.end(JSON.stringify({ error: 'unauthorized', code: status }))
323
+ return
324
+ }
325
+ }
326
+
298
327
  const target = runtime()
299
328
  if (request.method === 'GET' || request.method === 'HEAD') {
300
329
  const isFresh = Boolean(
@@ -317,7 +346,7 @@ export function registerPluginUpdater(ctx, options = {}) {
317
346
  response.end()
318
347
  return
319
348
  }
320
- if (!isTrustedUpdateRequest(request)) {
349
+ if (!isTrustedUpdateRequest(request, ctx)) {
321
350
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
322
351
  return
323
352
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",