@pan-sec/notebooklm-mcp 2026.2.11 → 2026.3.1

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 (305) hide show
  1. package/README.md +62 -19
  2. package/SECURITY.md +31 -61
  3. package/dist/auth/auth-manager.d.ts +2 -1
  4. package/dist/auth/auth-manager.d.ts.map +1 -1
  5. package/dist/auth/auth-manager.js +117 -44
  6. package/dist/auth/auth-manager.js.map +1 -1
  7. package/dist/auth/mcp-auth.d.ts +24 -4
  8. package/dist/auth/mcp-auth.d.ts.map +1 -1
  9. package/dist/auth/mcp-auth.js +149 -19
  10. package/dist/auth/mcp-auth.js.map +1 -1
  11. package/dist/compliance/alert-manager.d.ts.map +1 -1
  12. package/dist/compliance/alert-manager.js +7 -4
  13. package/dist/compliance/alert-manager.js.map +1 -1
  14. package/dist/compliance/breach-detection.d.ts.map +1 -1
  15. package/dist/compliance/breach-detection.js +14 -7
  16. package/dist/compliance/breach-detection.js.map +1 -1
  17. package/dist/compliance/change-log.d.ts.map +1 -1
  18. package/dist/compliance/change-log.js +7 -4
  19. package/dist/compliance/change-log.js.map +1 -1
  20. package/dist/compliance/compliance-logger.d.ts.map +1 -1
  21. package/dist/compliance/compliance-logger.js +11 -6
  22. package/dist/compliance/compliance-logger.js.map +1 -1
  23. package/dist/compliance/consent-manager.d.ts.map +1 -1
  24. package/dist/compliance/consent-manager.js +5 -3
  25. package/dist/compliance/consent-manager.js.map +1 -1
  26. package/dist/compliance/data-erasure.d.ts +1 -1
  27. package/dist/compliance/data-erasure.d.ts.map +1 -1
  28. package/dist/compliance/data-erasure.js +142 -83
  29. package/dist/compliance/data-erasure.js.map +1 -1
  30. package/dist/compliance/data-export.d.ts.map +1 -1
  31. package/dist/compliance/data-export.js +23 -12
  32. package/dist/compliance/data-export.js.map +1 -1
  33. package/dist/compliance/data-inventory.d.ts.map +1 -1
  34. package/dist/compliance/data-inventory.js +7 -6
  35. package/dist/compliance/data-inventory.js.map +1 -1
  36. package/dist/compliance/dsar-handler.d.ts +7 -1
  37. package/dist/compliance/dsar-handler.d.ts.map +1 -1
  38. package/dist/compliance/dsar-handler.js +74 -61
  39. package/dist/compliance/dsar-handler.js.map +1 -1
  40. package/dist/compliance/evidence-collector.d.ts.map +1 -1
  41. package/dist/compliance/evidence-collector.js +10 -6
  42. package/dist/compliance/evidence-collector.js.map +1 -1
  43. package/dist/compliance/health-monitor.d.ts.map +1 -1
  44. package/dist/compliance/health-monitor.js +15 -9
  45. package/dist/compliance/health-monitor.js.map +1 -1
  46. package/dist/compliance/incident-manager.d.ts.map +1 -1
  47. package/dist/compliance/incident-manager.js +5 -3
  48. package/dist/compliance/incident-manager.js.map +1 -1
  49. package/dist/compliance/policy-docs.d.ts.map +1 -1
  50. package/dist/compliance/policy-docs.js +14 -11
  51. package/dist/compliance/policy-docs.js.map +1 -1
  52. package/dist/compliance/privacy-notice-text.d.ts.map +1 -1
  53. package/dist/compliance/privacy-notice-text.js +3 -4
  54. package/dist/compliance/privacy-notice-text.js.map +1 -1
  55. package/dist/compliance/privacy-notice.d.ts.map +1 -1
  56. package/dist/compliance/privacy-notice.js +5 -3
  57. package/dist/compliance/privacy-notice.js.map +1 -1
  58. package/dist/compliance/report-generator.d.ts.map +1 -1
  59. package/dist/compliance/report-generator.js +5 -3
  60. package/dist/compliance/report-generator.js.map +1 -1
  61. package/dist/compliance/retention-engine.d.ts.map +1 -1
  62. package/dist/compliance/retention-engine.js +24 -10
  63. package/dist/compliance/retention-engine.js.map +1 -1
  64. package/dist/compliance/siem-exporter.d.ts.map +1 -1
  65. package/dist/compliance/siem-exporter.js +40 -16
  66. package/dist/compliance/siem-exporter.js.map +1 -1
  67. package/dist/config.d.ts +8 -31
  68. package/dist/config.d.ts.map +1 -1
  69. package/dist/config.js +26 -64
  70. package/dist/config.js.map +1 -1
  71. package/dist/errors.d.ts +22 -2
  72. package/dist/errors.d.ts.map +1 -1
  73. package/dist/errors.js +55 -4
  74. package/dist/errors.js.map +1 -1
  75. package/dist/gemini/gemini-client.d.ts +1 -0
  76. package/dist/gemini/gemini-client.d.ts.map +1 -1
  77. package/dist/gemini/gemini-client.js +50 -49
  78. package/dist/gemini/gemini-client.js.map +1 -1
  79. package/dist/gemini/types.d.ts +3 -1
  80. package/dist/gemini/types.d.ts.map +1 -1
  81. package/dist/gemini/types.js.map +1 -1
  82. package/dist/index.d.ts +52 -1
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +412 -89
  85. package/dist/index.js.map +1 -1
  86. package/dist/library/notebook-library.d.ts.map +1 -1
  87. package/dist/library/notebook-library.js +2 -1
  88. package/dist/library/notebook-library.js.map +1 -1
  89. package/dist/logging/query-logger.d.ts +13 -1
  90. package/dist/logging/query-logger.d.ts.map +1 -1
  91. package/dist/logging/query-logger.js +62 -10
  92. package/dist/logging/query-logger.js.map +1 -1
  93. package/dist/notebook-creation/audio-manager.d.ts.map +1 -1
  94. package/dist/notebook-creation/audio-manager.js +19 -24
  95. package/dist/notebook-creation/audio-manager.js.map +1 -1
  96. package/dist/notebook-creation/browser-options.d.ts +28 -0
  97. package/dist/notebook-creation/browser-options.d.ts.map +1 -0
  98. package/dist/notebook-creation/browser-options.js +75 -0
  99. package/dist/notebook-creation/browser-options.js.map +1 -0
  100. package/dist/notebook-creation/data-table-manager.d.ts.map +1 -1
  101. package/dist/notebook-creation/data-table-manager.js +20 -21
  102. package/dist/notebook-creation/data-table-manager.js.map +1 -1
  103. package/dist/notebook-creation/discover-creation-flow.d.ts +0 -6
  104. package/dist/notebook-creation/discover-creation-flow.d.ts.map +1 -1
  105. package/dist/notebook-creation/discover-creation-flow.js +10 -10
  106. package/dist/notebook-creation/discover-creation-flow.js.map +1 -1
  107. package/dist/notebook-creation/discover-quota.d.ts +0 -6
  108. package/dist/notebook-creation/discover-quota.d.ts.map +1 -1
  109. package/dist/notebook-creation/discover-quota.js +12 -13
  110. package/dist/notebook-creation/discover-quota.js.map +1 -1
  111. package/dist/notebook-creation/discover-sources.js +15 -16
  112. package/dist/notebook-creation/discover-sources.js.map +1 -1
  113. package/dist/notebook-creation/dom-scripts.d.ts +10 -0
  114. package/dist/notebook-creation/dom-scripts.d.ts.map +1 -0
  115. package/dist/notebook-creation/dom-scripts.js +58 -0
  116. package/dist/notebook-creation/dom-scripts.js.map +1 -0
  117. package/dist/notebook-creation/errors.d.ts +18 -0
  118. package/dist/notebook-creation/errors.d.ts.map +1 -0
  119. package/dist/notebook-creation/errors.js +20 -0
  120. package/dist/notebook-creation/errors.js.map +1 -0
  121. package/dist/notebook-creation/index.d.ts +2 -1
  122. package/dist/notebook-creation/index.d.ts.map +1 -1
  123. package/dist/notebook-creation/index.js +2 -1
  124. package/dist/notebook-creation/index.js.map +1 -1
  125. package/dist/notebook-creation/notebook-creator.d.ts +6 -82
  126. package/dist/notebook-creation/notebook-creator.d.ts.map +1 -1
  127. package/dist/notebook-creation/notebook-creator.js +49 -835
  128. package/dist/notebook-creation/notebook-creator.js.map +1 -1
  129. package/dist/notebook-creation/notebook-nav.d.ts +19 -0
  130. package/dist/notebook-creation/notebook-nav.d.ts.map +1 -0
  131. package/dist/notebook-creation/notebook-nav.js +240 -0
  132. package/dist/notebook-creation/notebook-nav.js.map +1 -0
  133. package/dist/notebook-creation/notebook-sync.d.ts.map +1 -1
  134. package/dist/notebook-creation/notebook-sync.js +36 -38
  135. package/dist/notebook-creation/notebook-sync.js.map +1 -1
  136. package/dist/notebook-creation/selector-discovery.d.ts.map +1 -1
  137. package/dist/notebook-creation/selector-discovery.js +17 -24
  138. package/dist/notebook-creation/selector-discovery.js.map +1 -1
  139. package/dist/notebook-creation/selectors.d.ts +23 -37
  140. package/dist/notebook-creation/selectors.d.ts.map +1 -1
  141. package/dist/notebook-creation/selectors.js +56 -60
  142. package/dist/notebook-creation/selectors.js.map +1 -1
  143. package/dist/notebook-creation/source-manager.d.ts +25 -0
  144. package/dist/notebook-creation/source-manager.d.ts.map +1 -1
  145. package/dist/notebook-creation/source-manager.js +689 -50
  146. package/dist/notebook-creation/source-manager.js.map +1 -1
  147. package/dist/notebook-creation/types.d.ts +4 -0
  148. package/dist/notebook-creation/types.d.ts.map +1 -1
  149. package/dist/notebook-creation/video-manager.d.ts.map +1 -1
  150. package/dist/notebook-creation/video-manager.js +33 -35
  151. package/dist/notebook-creation/video-manager.js.map +1 -1
  152. package/dist/observability/metrics.d.ts +19 -0
  153. package/dist/observability/metrics.d.ts.map +1 -0
  154. package/dist/observability/metrics.js +35 -0
  155. package/dist/observability/metrics.js.map +1 -0
  156. package/dist/quota/quota-manager.d.ts +11 -3
  157. package/dist/quota/quota-manager.d.ts.map +1 -1
  158. package/dist/quota/quota-manager.js +139 -47
  159. package/dist/quota/quota-manager.js.map +1 -1
  160. package/dist/resources/resource-handlers.d.ts.map +1 -1
  161. package/dist/resources/resource-handlers.js +39 -17
  162. package/dist/resources/resource-handlers.js.map +1 -1
  163. package/dist/session/browser-session.d.ts.map +1 -1
  164. package/dist/session/browser-session.js +22 -22
  165. package/dist/session/browser-session.js.map +1 -1
  166. package/dist/session/session-timeout.d.ts.map +1 -1
  167. package/dist/session/session-timeout.js +4 -2
  168. package/dist/session/session-timeout.js.map +1 -1
  169. package/dist/session/shared-context-manager.d.ts.map +1 -1
  170. package/dist/session/shared-context-manager.js +31 -30
  171. package/dist/session/shared-context-manager.js.map +1 -1
  172. package/dist/tools/annotations.d.ts.map +1 -1
  173. package/dist/tools/annotations.js +9 -56
  174. package/dist/tools/annotations.js.map +1 -1
  175. package/dist/tools/definitions/ask-question.d.ts.map +1 -1
  176. package/dist/tools/definitions/ask-question.js +35 -100
  177. package/dist/tools/definitions/ask-question.js.map +1 -1
  178. package/dist/tools/definitions/chat-history.d.ts +47 -1
  179. package/dist/tools/definitions/chat-history.d.ts.map +1 -1
  180. package/dist/tools/definitions/chat-history.js +10 -1
  181. package/dist/tools/definitions/chat-history.js.map +1 -1
  182. package/dist/tools/definitions/data-tables.d.ts.map +1 -1
  183. package/dist/tools/definitions/data-tables.js +2 -0
  184. package/dist/tools/definitions/data-tables.js.map +1 -1
  185. package/dist/tools/definitions/gemini.d.ts.map +1 -1
  186. package/dist/tools/definitions/gemini.js +54 -11
  187. package/dist/tools/definitions/gemini.js.map +1 -1
  188. package/dist/tools/definitions/notebook-management.d.ts.map +1 -1
  189. package/dist/tools/definitions/notebook-management.js +100 -70
  190. package/dist/tools/definitions/notebook-management.js.map +1 -1
  191. package/dist/tools/definitions/query-history.d.ts +47 -1
  192. package/dist/tools/definitions/query-history.d.ts.map +1 -1
  193. package/dist/tools/definitions/query-history.js +7 -0
  194. package/dist/tools/definitions/query-history.js.map +1 -1
  195. package/dist/tools/definitions/session-management.d.ts.map +1 -1
  196. package/dist/tools/definitions/session-management.js +5 -0
  197. package/dist/tools/definitions/session-management.js.map +1 -1
  198. package/dist/tools/definitions/system.d.ts.map +1 -1
  199. package/dist/tools/definitions/system.js +71 -100
  200. package/dist/tools/definitions/system.js.map +1 -1
  201. package/dist/tools/definitions/video.d.ts.map +1 -1
  202. package/dist/tools/definitions/video.js +4 -1
  203. package/dist/tools/definitions/video.js.map +1 -1
  204. package/dist/tools/definitions.d.ts.map +1 -1
  205. package/dist/tools/definitions.js +4 -0
  206. package/dist/tools/definitions.js.map +1 -1
  207. package/dist/tools/handlers/ask-question.d.ts +1 -1
  208. package/dist/tools/handlers/ask-question.d.ts.map +1 -1
  209. package/dist/tools/handlers/ask-question.js +57 -13
  210. package/dist/tools/handlers/ask-question.js.map +1 -1
  211. package/dist/tools/handlers/audio-video.d.ts.map +1 -1
  212. package/dist/tools/handlers/audio-video.js +22 -161
  213. package/dist/tools/handlers/audio-video.js.map +1 -1
  214. package/dist/tools/handlers/auth.d.ts +14 -19
  215. package/dist/tools/handlers/auth.d.ts.map +1 -1
  216. package/dist/tools/handlers/auth.js +77 -121
  217. package/dist/tools/handlers/auth.js.map +1 -1
  218. package/dist/tools/handlers/error-utils.d.ts +16 -0
  219. package/dist/tools/handlers/error-utils.d.ts.map +1 -0
  220. package/dist/tools/handlers/error-utils.js +39 -0
  221. package/dist/tools/handlers/error-utils.js.map +1 -0
  222. package/dist/tools/handlers/gemini.d.ts +2 -0
  223. package/dist/tools/handlers/gemini.d.ts.map +1 -1
  224. package/dist/tools/handlers/gemini.js +88 -51
  225. package/dist/tools/handlers/gemini.js.map +1 -1
  226. package/dist/tools/handlers/index.d.ts +39 -47
  227. package/dist/tools/handlers/index.d.ts.map +1 -1
  228. package/dist/tools/handlers/index.js +15 -4
  229. package/dist/tools/handlers/index.js.map +1 -1
  230. package/dist/tools/handlers/notebook-creation.d.ts.map +1 -1
  231. package/dist/tools/handlers/notebook-creation.js +102 -86
  232. package/dist/tools/handlers/notebook-creation.js.map +1 -1
  233. package/dist/tools/handlers/notebook-management.d.ts +8 -8
  234. package/dist/tools/handlers/notebook-management.d.ts.map +1 -1
  235. package/dist/tools/handlers/notebook-management.js +34 -80
  236. package/dist/tools/handlers/notebook-management.js.map +1 -1
  237. package/dist/tools/handlers/session-management.d.ts +8 -10
  238. package/dist/tools/handlers/session-management.d.ts.map +1 -1
  239. package/dist/tools/handlers/session-management.js +34 -63
  240. package/dist/tools/handlers/session-management.js.map +1 -1
  241. package/dist/tools/handlers/system.d.ts.map +1 -1
  242. package/dist/tools/handlers/system.js +45 -10
  243. package/dist/tools/handlers/system.js.map +1 -1
  244. package/dist/tools/handlers/types.d.ts +1 -1
  245. package/dist/tools/handlers/types.d.ts.map +1 -1
  246. package/dist/tools/handlers/webhooks.d.ts.map +1 -1
  247. package/dist/tools/handlers/webhooks.js +15 -13
  248. package/dist/tools/handlers/webhooks.js.map +1 -1
  249. package/dist/types.d.ts +7 -17
  250. package/dist/types.d.ts.map +1 -1
  251. package/dist/utils/audit-logger.d.ts +19 -1
  252. package/dist/utils/audit-logger.d.ts.map +1 -1
  253. package/dist/utils/audit-logger.js +198 -30
  254. package/dist/utils/audit-logger.js.map +1 -1
  255. package/dist/utils/cleanup-manager.d.ts.map +1 -1
  256. package/dist/utils/cleanup-manager.js +6 -3
  257. package/dist/utils/cleanup-manager.js.map +1 -1
  258. package/dist/utils/crypto.d.ts +4 -1
  259. package/dist/utils/crypto.d.ts.map +1 -1
  260. package/dist/utils/crypto.js +32 -21
  261. package/dist/utils/crypto.js.map +1 -1
  262. package/dist/utils/file-lock.d.ts.map +1 -1
  263. package/dist/utils/file-lock.js +87 -16
  264. package/dist/utils/file-lock.js.map +1 -1
  265. package/dist/utils/file-permissions.d.ts +2 -0
  266. package/dist/utils/file-permissions.d.ts.map +1 -1
  267. package/dist/utils/file-permissions.js +2 -1
  268. package/dist/utils/file-permissions.js.map +1 -1
  269. package/dist/utils/logger.d.ts +4 -0
  270. package/dist/utils/logger.d.ts.map +1 -1
  271. package/dist/utils/logger.js +16 -0
  272. package/dist/utils/logger.js.map +1 -1
  273. package/dist/utils/page-utils.d.ts +13 -0
  274. package/dist/utils/page-utils.d.ts.map +1 -1
  275. package/dist/utils/page-utils.js +61 -39
  276. package/dist/utils/page-utils.js.map +1 -1
  277. package/dist/utils/response-validator.d.ts.map +1 -1
  278. package/dist/utils/response-validator.js +27 -22
  279. package/dist/utils/response-validator.js.map +1 -1
  280. package/dist/utils/secrets-scanner.d.ts +11 -0
  281. package/dist/utils/secrets-scanner.d.ts.map +1 -1
  282. package/dist/utils/secrets-scanner.js +65 -17
  283. package/dist/utils/secrets-scanner.js.map +1 -1
  284. package/dist/utils/secure-memory.d.ts +9 -31
  285. package/dist/utils/secure-memory.d.ts.map +1 -1
  286. package/dist/utils/secure-memory.js +17 -102
  287. package/dist/utils/secure-memory.js.map +1 -1
  288. package/dist/utils/security.d.ts +4 -3
  289. package/dist/utils/security.d.ts.map +1 -1
  290. package/dist/utils/security.js +43 -13
  291. package/dist/utils/security.js.map +1 -1
  292. package/dist/utils/stealth-utils.d.ts.map +1 -1
  293. package/dist/utils/stealth-utils.js +4 -4
  294. package/dist/utils/stealth-utils.js.map +1 -1
  295. package/dist/webhooks/types.d.ts +4 -0
  296. package/dist/webhooks/types.d.ts.map +1 -1
  297. package/dist/webhooks/webhook-dispatcher.d.ts +80 -12
  298. package/dist/webhooks/webhook-dispatcher.d.ts.map +1 -1
  299. package/dist/webhooks/webhook-dispatcher.js +497 -74
  300. package/dist/webhooks/webhook-dispatcher.js.map +1 -1
  301. package/docs/archive/ISSUES-legacy-2026-04-24.md +644 -0
  302. package/docs/dependency-risk.md +25 -0
  303. package/docs/testing-runbook.md +166 -0
  304. package/docs/usage-guide.md +2 -1
  305. package/package.json +34 -16
@@ -6,21 +6,190 @@
6
6
  import crypto from "crypto";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
+ import net from "node:net";
10
+ import dns from "node:dns/promises";
9
11
  import { log } from "../utils/logger.js";
10
12
  import { writeFileSecure, PERMISSION_MODES } from "../utils/file-permissions.js";
11
13
  import { CONFIG } from "../config.js";
12
14
  import { eventEmitter } from "../events/event-emitter.js";
15
+ import { scanAndRedactSecrets } from "../utils/secrets-scanner.js";
16
+ import { SecureCredential } from "../utils/secure-memory.js";
17
+ import { getMetricsRegistry } from "../observability/metrics.js";
18
+ // Headers that must never be forwarded from user-configured webhook.headers
19
+ // — they are either hop-by-hop (Host), auth-overriding (Authorization),
20
+ // or would confuse the transport (Content-Length, Transfer-Encoding).
21
+ const BLOCKED_OUTBOUND_HEADERS = new Set([
22
+ "host",
23
+ "authorization",
24
+ "content-length",
25
+ "transfer-encoding",
26
+ "connection",
27
+ ]);
28
+ /**
29
+ * Classify an IPv4 address as private/loopback/link-local/metadata.
30
+ * See RFC 1918, RFC 3927, RFC 6598, RFC 5735.
31
+ */
32
+ function isPrivateIPv4(addr) {
33
+ const parts = addr.split(".").map((p) => parseInt(p, 10));
34
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p)))
35
+ return false;
36
+ const [a, b] = parts;
37
+ if (a === 0)
38
+ return true; // 0.0.0.0/8
39
+ if (a === 10)
40
+ return true; // 10.0.0.0/8
41
+ if (a === 100 && b >= 64 && b <= 127)
42
+ return true; // 100.64.0.0/10 CGNAT
43
+ if (a === 127)
44
+ return true; // 127.0.0.0/8 loopback
45
+ if (a === 169 && b === 254)
46
+ return true; // 169.254.0.0/16 link-local + AWS/GCP metadata
47
+ if (a === 172 && b >= 16 && b <= 31)
48
+ return true; // 172.16.0.0/12
49
+ if (a === 192 && b === 168)
50
+ return true; // 192.168.0.0/16
51
+ if (a >= 224)
52
+ return true; // multicast + reserved
53
+ return false;
54
+ }
55
+ function isPrivateIPv6(addr) {
56
+ const lower = addr.toLowerCase();
57
+ if (lower === "::1" || lower === "::")
58
+ return true; // loopback, unspecified
59
+ if (lower.startsWith("fe80:") || lower.startsWith("fe80::"))
60
+ return true; // link-local
61
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
62
+ return true; // unique local fc00::/7
63
+ if (lower.startsWith("ff"))
64
+ return true; // multicast
65
+ // IPv4-mapped IPv6. Node's URL parser normalizes dotted-quad form to
66
+ // compressed hex (::ffff:169.254.169.254 -> ::ffff:a9fe:a9fe), so we
67
+ // accept both and recover the IPv4 for range-checking.
68
+ if (lower.startsWith("::ffff:")) {
69
+ const rest = lower.slice(7);
70
+ if (net.isIPv4(rest))
71
+ return isPrivateIPv4(rest);
72
+ const parts = rest.split(":");
73
+ if (parts.length === 2 && /^[0-9a-f]{1,4}$/.test(parts[0]) && /^[0-9a-f]{1,4}$/.test(parts[1])) {
74
+ const hi = parseInt(parts[0], 16);
75
+ const lo = parseInt(parts[1], 16);
76
+ const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
77
+ return isPrivateIPv4(ipv4);
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+ function isPrivateHost(hostname) {
83
+ let h = hostname.toLowerCase();
84
+ // WHATWG URL returns IPv6 hostnames wrapped in brackets (e.g. "[::1]").
85
+ // Strip them so net.isIPv6 / IPv6 range checks work.
86
+ if (h.startsWith("[") && h.endsWith("]"))
87
+ h = h.slice(1, -1);
88
+ if (h === "localhost" || h === "localhost.localdomain")
89
+ return true;
90
+ if (h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal"))
91
+ return true;
92
+ if (net.isIPv4(h) && isPrivateIPv4(h))
93
+ return true;
94
+ if (net.isIPv6(h) && isPrivateIPv6(h))
95
+ return true;
96
+ return false;
97
+ }
98
+ /**
99
+ * Validate a webhook URL before we ever send it an outbound request.
100
+ *
101
+ * Checks (in order):
102
+ * 1. Parseable URL
103
+ * 2. Scheme: require https:; allow http: only when NLMCP_WEBHOOK_ALLOW_HTTP=true
104
+ * 3. Lexical hostname in private/loopback/link-local/metadata space
105
+ * 4. DNS resolution — all resolved addresses must be public (closes
106
+ * DNS-rebinding attacks); skipped when NLMCP_WEBHOOK_RESOLVE_DNS=false
107
+ *
108
+ * Exported for use by tool handlers that want to pre-validate before
109
+ * calling dispatcher.addWebhook().
110
+ */
111
+ export async function validateWebhookUrl(rawUrl) {
112
+ let parsed;
113
+ try {
114
+ parsed = new URL(rawUrl);
115
+ }
116
+ catch (err) {
117
+ log.debug(`webhook-dispatcher: parsing webhook URL in validateWebhookUrl: ${err instanceof Error ? err.message : String(err)}`);
118
+ return { ok: false, error: "invalid URL" };
119
+ }
120
+ const allowHttp = process.env.NLMCP_WEBHOOK_ALLOW_HTTP === "true";
121
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && allowHttp)) {
122
+ return {
123
+ ok: false,
124
+ error: `scheme '${parsed.protocol}' not allowed (need https:; set NLMCP_WEBHOOK_ALLOW_HTTP=true to permit http:)`,
125
+ };
126
+ }
127
+ const host = parsed.hostname;
128
+ if (!host)
129
+ return { ok: false, error: "URL missing hostname" };
130
+ if (isPrivateHost(host)) {
131
+ return {
132
+ ok: false,
133
+ error: `hostname '${host}' is in a private/loopback/link-local range (SSRF block)`,
134
+ };
135
+ }
136
+ if (process.env.NLMCP_WEBHOOK_RESOLVE_DNS !== "false" && !net.isIP(host)) {
137
+ try {
138
+ const addresses = await Promise.race([
139
+ dns.lookup(host, { all: true }),
140
+ new Promise((_, reject) => setTimeout(() => reject(new Error("DNS lookup timed out after 2s")), 2000)),
141
+ ]);
142
+ for (const { address, family } of addresses) {
143
+ if (family === 4 && isPrivateIPv4(address)) {
144
+ return {
145
+ ok: false,
146
+ error: `hostname '${host}' resolves to private IPv4 ${address} (SSRF block)`,
147
+ };
148
+ }
149
+ if (family === 6 && isPrivateIPv6(address)) {
150
+ return {
151
+ ok: false,
152
+ error: `hostname '${host}' resolves to private IPv6 ${address} (SSRF block)`,
153
+ };
154
+ }
155
+ }
156
+ }
157
+ catch (err) {
158
+ return {
159
+ ok: false,
160
+ error: `DNS resolution failed for '${host}': ${err instanceof Error ? err.message : String(err)}`,
161
+ };
162
+ }
163
+ }
164
+ return { ok: true, url: parsed };
165
+ }
166
+ const WEBHOOK_SECRET_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
13
167
  export class WebhookDispatcher {
14
168
  storePath;
169
+ deliveryLogPath;
15
170
  store;
16
171
  unsubscribe = null;
17
172
  deliveryHistory = [];
18
173
  maxDeliveryHistory = 100;
174
+ circuitBreakerThreshold = 5;
175
+ circuitBreakerResetMs = 60000;
176
+ circuitBreakers = new Map();
177
+ deliverySequence = 0;
178
+ // In-memory SecureCredential store for webhook secrets (I321)
179
+ webhookSecrets = new Map();
180
+ // Serialises saveStore() writes so concurrent addWebhook/removeWebhook don't interleave (I277)
181
+ saveQueue = Promise.resolve();
19
182
  constructor() {
20
183
  this.storePath = path.join(CONFIG.dataDir, "webhooks.json");
184
+ this.deliveryLogPath = path.join(CONFIG.dataDir, "webhook-deliveries.jsonl");
21
185
  this.store = this.loadStore();
22
- this.initializeFromEnv();
186
+ this.loadDeliveryHistory();
23
187
  this.subscribeToEvents();
188
+ // Env-driven webhook init is async (URL validation calls dns.lookup);
189
+ // fire and forget — webhooks registered from env appear after the
190
+ // promise resolves. Constructor invariants (listWebhooks, dispatch with
191
+ // existing stored webhooks) are preserved.
192
+ void this.initializeFromEnv().catch((err) => log.warning(`WebhookDispatcher env init failed: ${err instanceof Error ? err.message : String(err)}`));
24
193
  log.info("🔔 WebhookDispatcher initialized");
25
194
  log.info(` Webhooks: ${this.store.webhooks.filter((w) => w.enabled).length} active`);
26
195
  }
@@ -47,53 +216,63 @@ export class WebhookDispatcher {
47
216
  * Save webhooks to disk
48
217
  */
49
218
  saveStore() {
50
- try {
51
- const data = JSON.stringify(this.store, null, 2);
52
- writeFileSecure(this.storePath, data, PERMISSION_MODES.OWNER_READ_WRITE);
53
- }
54
- catch (error) {
55
- log.error(`Failed to save webhooks: ${error}`);
56
- }
219
+ // Chain onto the existing save to serialise concurrent mutation (I277)
220
+ this.saveQueue = this.saveQueue.then(() => {
221
+ try {
222
+ const data = JSON.stringify(this.store, null, 2);
223
+ writeFileSecure(this.storePath, data, PERMISSION_MODES.OWNER_READ_WRITE);
224
+ }
225
+ catch (error) {
226
+ log.error(`Failed to save webhooks: ${error}`);
227
+ }
228
+ });
57
229
  }
58
230
  /**
59
- * Initialize webhooks from environment variables
231
+ * Initialize webhooks from environment variables. Each URL is validated
232
+ * via validateWebhookUrl before being stored — invalid env values log a
233
+ * warning and are skipped (server must still start).
60
234
  */
61
- initializeFromEnv() {
62
- // Check for NLMCP_WEBHOOK_URL
235
+ async initializeFromEnv() {
236
+ const tryAdd = async (envVar, input) => {
237
+ if (this.store.webhooks.some((w) => w.url === input.url))
238
+ return;
239
+ try {
240
+ await this.addWebhook(input);
241
+ log.info(` Added webhook from env (${envVar}): host=${new URL(input.url).host}`);
242
+ }
243
+ catch (err) {
244
+ log.warning(` ⚠️ Skipping ${envVar} — ${err instanceof Error ? err.message : String(err)}`);
245
+ }
246
+ };
63
247
  const webhookUrl = process.env.NLMCP_WEBHOOK_URL;
64
- if (webhookUrl && !this.store.webhooks.some((w) => w.url === webhookUrl)) {
248
+ if (webhookUrl) {
65
249
  const events = process.env.NLMCP_WEBHOOK_EVENTS
66
250
  ? process.env.NLMCP_WEBHOOK_EVENTS.split(",")
67
251
  : ["*"];
68
- this.addWebhook({
252
+ await tryAdd("NLMCP_WEBHOOK_URL", {
69
253
  name: "Default Webhook",
70
254
  url: webhookUrl,
71
255
  events,
72
256
  secret: process.env.NLMCP_WEBHOOK_SECRET,
73
257
  });
74
- log.info(` Added webhook from env: ${webhookUrl}`);
75
258
  }
76
- // Check for Slack webhook
77
259
  const slackUrl = process.env.NLMCP_SLACK_WEBHOOK_URL;
78
- if (slackUrl && !this.store.webhooks.some((w) => w.url === slackUrl)) {
79
- this.addWebhook({
260
+ if (slackUrl) {
261
+ await tryAdd("NLMCP_SLACK_WEBHOOK_URL", {
80
262
  name: "Slack Notifications",
81
263
  url: slackUrl,
82
264
  events: ["*"],
83
265
  format: "slack",
84
266
  });
85
- log.info(` Added Slack webhook from env`);
86
267
  }
87
- // Check for Discord webhook
88
268
  const discordUrl = process.env.NLMCP_DISCORD_WEBHOOK_URL;
89
- if (discordUrl && !this.store.webhooks.some((w) => w.url === discordUrl)) {
90
- this.addWebhook({
269
+ if (discordUrl) {
270
+ await tryAdd("NLMCP_DISCORD_WEBHOOK_URL", {
91
271
  name: "Discord Notifications",
92
272
  url: discordUrl,
93
273
  events: ["*"],
94
274
  format: "discord",
95
275
  });
96
- log.info(` Added Discord webhook from env`);
97
276
  }
98
277
  }
99
278
  /**
@@ -105,15 +284,16 @@ export class WebhookDispatcher {
105
284
  });
106
285
  }
107
286
  /**
108
- * Dispatch an event to all matching webhooks
287
+ * Dispatch an event to all matching webhooks in parallel.
288
+ *
289
+ * Using Promise.allSettled so one slow/failing webhook does not block
290
+ * or cancel delivery to others (I275).
109
291
  */
110
292
  async dispatch(event) {
111
- const enabledWebhooks = this.store.webhooks.filter((w) => w.enabled);
112
- for (const webhook of enabledWebhooks) {
113
- if (this.shouldSend(webhook, event.type)) {
114
- await this.sendWithRetry(webhook, event);
115
- }
116
- }
293
+ const targets = this.store.webhooks.filter((w) => w.enabled && this.shouldSend(w, event.type));
294
+ if (targets.length === 0)
295
+ return;
296
+ await Promise.allSettled(targets.map((w) => this.sendWithRetry(w, event)));
117
297
  }
118
298
  /**
119
299
  * Check if webhook should receive this event type
@@ -123,37 +303,149 @@ export class WebhookDispatcher {
123
303
  return true;
124
304
  return webhook.events.includes(eventType);
125
305
  }
306
+ getCircuitBreakerState(webhookId) {
307
+ const existing = this.circuitBreakers.get(webhookId);
308
+ if (existing)
309
+ return existing;
310
+ const state = {
311
+ consecutiveFailures: 0,
312
+ halfOpenProbeInFlight: false,
313
+ };
314
+ this.circuitBreakers.set(webhookId, state);
315
+ return state;
316
+ }
317
+ shouldSkipForOpenCircuit(webhook) {
318
+ const state = this.getCircuitBreakerState(webhook.id);
319
+ if (!state.openUntil)
320
+ return false;
321
+ const now = Date.now();
322
+ if (state.openUntil > now) {
323
+ log.warning(`webhook_dispatcher circuit_open ${JSON.stringify({
324
+ webhookId: webhook.id,
325
+ webhookName: webhook.name,
326
+ urlHost: this.safeHost(webhook.url),
327
+ openUntil: new Date(state.openUntil).toISOString(),
328
+ })}`);
329
+ return true;
330
+ }
331
+ if (state.halfOpenProbeInFlight) {
332
+ log.warning(`webhook_dispatcher circuit_half_open_busy ${JSON.stringify({
333
+ webhookId: webhook.id,
334
+ webhookName: webhook.name,
335
+ urlHost: this.safeHost(webhook.url),
336
+ })}`);
337
+ return true;
338
+ }
339
+ state.halfOpenProbeInFlight = true;
340
+ log.warning(`webhook_dispatcher circuit_half_open_probe ${JSON.stringify({
341
+ webhookId: webhook.id,
342
+ webhookName: webhook.name,
343
+ urlHost: this.safeHost(webhook.url),
344
+ })}`);
345
+ return false;
346
+ }
347
+ onDeliverySuccess(webhook) {
348
+ const state = this.getCircuitBreakerState(webhook.id);
349
+ state.consecutiveFailures = 0;
350
+ state.openUntil = undefined;
351
+ state.halfOpenProbeInFlight = false;
352
+ }
353
+ onDeliveryFailure(webhook) {
354
+ const state = this.getCircuitBreakerState(webhook.id);
355
+ state.consecutiveFailures += 1;
356
+ state.halfOpenProbeInFlight = false;
357
+ if (state.consecutiveFailures >= this.circuitBreakerThreshold) {
358
+ state.openUntil = Date.now() + this.circuitBreakerResetMs;
359
+ log.warning(`webhook_dispatcher circuit_opened ${JSON.stringify({
360
+ webhookId: webhook.id,
361
+ webhookName: webhook.name,
362
+ urlHost: this.safeHost(webhook.url),
363
+ consecutiveFailures: state.consecutiveFailures,
364
+ openUntil: new Date(state.openUntil).toISOString(),
365
+ })}`);
366
+ }
367
+ }
368
+ logAttempt(webhook, event, attempt, maxAttempts, delivery) {
369
+ log.warning(`webhook_dispatcher delivery_attempt ${JSON.stringify({
370
+ webhookId: webhook.id,
371
+ webhookName: webhook.name,
372
+ eventType: event.type,
373
+ attempt,
374
+ maxAttempts,
375
+ success: delivery.success,
376
+ statusCode: delivery.statusCode,
377
+ error: delivery.error,
378
+ errorKind: delivery.errorKind,
379
+ durationMs: delivery.durationMs,
380
+ })}`);
381
+ }
126
382
  /**
127
- * Send event with retry logic
383
+ * Send event with retry logic.
384
+ *
385
+ * Retries capped at 3 attempts with max 30 s total window (I276).
386
+ * Payload is secrets-scanned before dispatch (I273).
387
+ * Outbound headers filtered to remove dangerous overrides (I282).
388
+ * HMAC signature includes unix timestamp to prevent replay (I271).
128
389
  */
129
390
  async sendWithRetry(webhook, event) {
130
- const maxAttempts = webhook.retryCount ?? 3;
131
- const baseDelay = webhook.retryDelayMs ?? 1000;
132
- const timeout = webhook.timeoutMs ?? 5000;
391
+ if (this.shouldSkipForOpenCircuit(webhook)) {
392
+ return false;
393
+ }
394
+ // Cap retries: max 3 attempts, max 10 s per-request timeout (I276)
395
+ const maxAttempts = Math.min(webhook.retryCount ?? 3, 3);
396
+ const baseDelay = Math.min(webhook.retryDelayMs ?? 1000, 2000);
397
+ const timeout = Math.min(webhook.timeoutMs ?? 5000, 10000);
133
398
  const deliveryId = crypto.randomUUID();
134
399
  const startTime = Date.now();
400
+ // Scan payload for secrets once before any attempt (I273)
401
+ const rawPayload = this.formatPayload(event, webhook.format);
402
+ let payload;
403
+ try {
404
+ const { clean } = await scanAndRedactSecrets(rawPayload);
405
+ payload = clean;
406
+ }
407
+ catch (err) {
408
+ log.debug(`webhook-dispatcher: scanning and redacting secrets from payload: ${err instanceof Error ? err.message : String(err)}`);
409
+ payload = rawPayload;
410
+ }
411
+ // Filter user-configured headers to block dangerous overrides (I282)
412
+ const safeCustomHeaders = Object.fromEntries(Object.entries(webhook.headers ?? {}).filter(([k]) => !BLOCKED_OUTBOUND_HEADERS.has(k.toLowerCase())));
135
413
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
136
414
  try {
137
- const payload = this.formatPayload(event, webhook.format);
138
- const signature = webhook.secret
139
- ? this.sign(payload, webhook.secret)
415
+ // Include unix timestamp in HMAC to prevent indefinite replay (I271)
416
+ const timestamp = Math.floor(Date.now() / 1000);
417
+ const secret = this.webhookSecrets.get(webhook.id)?.getValue() ?? webhook.secret;
418
+ const signature = secret
419
+ ? this.sign(payload, secret, timestamp)
140
420
  : undefined;
141
421
  const controller = new AbortController();
142
422
  const timeoutId = setTimeout(() => controller.abort(), timeout);
143
- const response = await fetch(webhook.url, {
144
- method: "POST",
145
- headers: {
146
- "Content-Type": "application/json",
147
- "User-Agent": "notebooklm-mcp/1.7.0",
148
- ...(signature && { "X-Webhook-Signature": signature }),
149
- ...(webhook.headers || {}),
150
- },
151
- body: payload,
152
- signal: controller.signal,
153
- });
154
- clearTimeout(timeoutId);
423
+ let response;
424
+ try {
425
+ response = await fetch(webhook.url, {
426
+ method: "POST",
427
+ headers: {
428
+ "Content-Type": "application/json",
429
+ "User-Agent": `notebooklm-mcp/${process.env.npm_package_version ?? "2026.2.11"}`,
430
+ ...(signature && {
431
+ "X-Webhook-Signature": signature,
432
+ "X-Webhook-Timestamp": String(timestamp),
433
+ }),
434
+ ...safeCustomHeaders,
435
+ },
436
+ body: payload,
437
+ signal: controller.signal,
438
+ // Refuse redirects — a pre-validated host redirecting to cloud
439
+ // metadata (169.254.169.254) would otherwise bypass validateWebhookUrl.
440
+ redirect: "error",
441
+ });
442
+ }
443
+ finally {
444
+ clearTimeout(timeoutId);
445
+ }
155
446
  const delivery = {
156
- id: deliveryId,
447
+ id: `${deliveryId}-${attempt}`,
448
+ sequence: this.nextDeliverySequence(),
157
449
  webhookId: webhook.id,
158
450
  eventType: event.type,
159
451
  timestamp: new Date().toISOString(),
@@ -163,7 +455,9 @@ export class WebhookDispatcher {
163
455
  durationMs: Date.now() - startTime,
164
456
  };
165
457
  this.recordDelivery(delivery);
458
+ this.logAttempt(webhook, event, attempt, maxAttempts, delivery);
166
459
  if (response.ok) {
460
+ this.onDeliverySuccess(webhook);
167
461
  log.dim(` ✅ Webhook delivered: ${webhook.name} (${event.type})`);
168
462
  return true;
169
463
  }
@@ -171,22 +465,47 @@ export class WebhookDispatcher {
171
465
  }
172
466
  catch (error) {
173
467
  const errorMessage = error instanceof Error ? error.message : String(error);
468
+ // Walk the cause chain: Node's fetch wraps DNS/connect errors as
469
+ // TypeError("fetch failed", { cause: Error("getaddrinfo ENOTFOUND ...") })
470
+ const causeMessages = [];
471
+ let cur = error;
472
+ while (cur instanceof Error) {
473
+ causeMessages.push(cur.message);
474
+ cur = cur.cause;
475
+ }
476
+ const combinedMessage = causeMessages.join(" ");
477
+ const isAbort = error instanceof DOMException && error.name === "AbortError";
478
+ const isDns = /ENOTFOUND|EAI_AGAIN|ECONNREFUSED/.test(combinedMessage);
479
+ const errorKind = isAbort
480
+ ? "timeout"
481
+ : isDns
482
+ ? "dns_or_connect"
483
+ : "network";
484
+ const delivery = {
485
+ id: `${deliveryId}-${attempt}`,
486
+ sequence: this.nextDeliverySequence(),
487
+ webhookId: webhook.id,
488
+ eventType: event.type,
489
+ timestamp: new Date().toISOString(),
490
+ success: false,
491
+ error: errorMessage,
492
+ errorKind,
493
+ attempts: attempt,
494
+ durationMs: Date.now() - startTime,
495
+ };
496
+ this.recordDelivery(delivery);
497
+ this.logAttempt(webhook, event, attempt, maxAttempts, delivery);
498
+ if (isDns) {
499
+ this.onDeliveryFailure(webhook);
500
+ log.error(` ❌ Webhook failed permanently: ${webhook.name} - ${errorKind}: ${errorMessage}`);
501
+ return false;
502
+ }
174
503
  if (attempt === maxAttempts) {
175
- const delivery = {
176
- id: deliveryId,
177
- webhookId: webhook.id,
178
- eventType: event.type,
179
- timestamp: new Date().toISOString(),
180
- success: false,
181
- error: errorMessage,
182
- attempts: attempt,
183
- durationMs: Date.now() - startTime,
184
- };
185
- this.recordDelivery(delivery);
186
- log.error(` ❌ Webhook failed permanently: ${webhook.name} - ${errorMessage}`);
504
+ this.onDeliveryFailure(webhook);
505
+ log.error(` ❌ Webhook failed permanently: ${webhook.name} - ${errorKind}: ${errorMessage}`);
187
506
  return false;
188
507
  }
189
- log.warning(` ⚠️ Webhook error (attempt ${attempt}/${maxAttempts}): ${webhook.name} - ${errorMessage}`);
508
+ log.warning(` ⚠️ Webhook error (attempt ${attempt}/${maxAttempts}): ${webhook.name} - ${errorKind}: ${errorMessage}`);
190
509
  }
191
510
  // Exponential backoff
192
511
  if (attempt < maxAttempts) {
@@ -194,6 +513,7 @@ export class WebhookDispatcher {
194
513
  await new Promise((r) => setTimeout(r, delay));
195
514
  }
196
515
  }
516
+ this.onDeliveryFailure(webhook);
197
517
  return false;
198
518
  }
199
519
  /**
@@ -369,27 +689,79 @@ export class WebhookDispatcher {
369
689
  }
370
690
  }
371
691
  /**
372
- * Sign payload with HMAC-SHA256
692
+ * Sign payload with HMAC-SHA256, including a unix timestamp in the signed
693
+ * data so receivers can reject replayed requests (I271).
694
+ * Signed message: "<timestamp>\n<payload>"
373
695
  */
374
- sign(payload, secret) {
696
+ sign(payload, secret, timestamp) {
375
697
  const hmac = crypto.createHmac("sha256", secret);
376
- hmac.update(payload);
698
+ hmac.update(`${timestamp}\n${payload}`);
377
699
  return `sha256=${hmac.digest("hex")}`;
378
700
  }
379
701
  /**
380
- * Record delivery for history
702
+ * Load recent delivery history from disk on startup (I279)
703
+ */
704
+ loadDeliveryHistory() {
705
+ try {
706
+ if (!fs.existsSync(this.deliveryLogPath))
707
+ return;
708
+ const lines = fs.readFileSync(this.deliveryLogPath, "utf-8").trim().split("\n");
709
+ // Load last maxDeliveryHistory lines to seed in-memory buffer
710
+ const recent = lines.slice(-this.maxDeliveryHistory);
711
+ for (const line of recent) {
712
+ try {
713
+ if (line.trim()) {
714
+ const delivery = JSON.parse(line);
715
+ this.deliveryHistory.push(delivery);
716
+ if (typeof delivery.sequence === "number" && delivery.sequence > this.deliverySequence) {
717
+ this.deliverySequence = delivery.sequence;
718
+ }
719
+ }
720
+ }
721
+ catch {
722
+ // skip malformed lines
723
+ }
724
+ }
725
+ }
726
+ catch (err) {
727
+ log.debug(`webhook-dispatcher: failed to load delivery history: ${err instanceof Error ? err.message : String(err)}`);
728
+ }
729
+ }
730
+ /**
731
+ * Record delivery for history — persists to disk for cross-restart auditability (I279)
381
732
  */
382
733
  recordDelivery(delivery) {
734
+ getMetricsRegistry().increment("webhook_deliveries_total", {
735
+ event_type: delivery.eventType,
736
+ success: delivery.success,
737
+ });
383
738
  this.deliveryHistory.push(delivery);
384
739
  if (this.deliveryHistory.length > this.maxDeliveryHistory) {
385
740
  this.deliveryHistory.shift();
386
741
  }
742
+ // Append to delivery log for durable audit trail
743
+ try {
744
+ fs.appendFileSync(this.deliveryLogPath, JSON.stringify(delivery) + "\n", { mode: 0o600 });
745
+ }
746
+ catch (err) {
747
+ log.debug(`webhook-dispatcher: failed to persist delivery record: ${err instanceof Error ? err.message : String(err)}`);
748
+ }
749
+ }
750
+ nextDeliverySequence() {
751
+ this.deliverySequence += 1;
752
+ return this.deliverySequence;
387
753
  }
388
754
  // === Public API ===
389
755
  /**
390
- * Add a new webhook
756
+ * Add a new webhook. Validates the URL before persisting; throws on
757
+ * scheme/host/DNS-resolution failure so callers see a clear reason.
758
+ * Records a ChangeLog entry for SOC2 change-management audit trail.
391
759
  */
392
- addWebhook(input) {
760
+ async addWebhook(input) {
761
+ const validation = await validateWebhookUrl(input.url);
762
+ if (!validation.ok) {
763
+ throw new Error(`webhook URL rejected: ${validation.error}`);
764
+ }
393
765
  const webhook = {
394
766
  id: crypto.randomUUID(),
395
767
  name: input.name,
@@ -397,7 +769,7 @@ export class WebhookDispatcher {
397
769
  enabled: true,
398
770
  events: input.events || ["*"],
399
771
  format: input.format || "generic",
400
- secret: input.secret,
772
+ secret: undefined, // secret never persisted to disk
401
773
  headers: input.headers,
402
774
  retryCount: 3,
403
775
  retryDelayMs: 1000,
@@ -405,19 +777,32 @@ export class WebhookDispatcher {
405
777
  createdAt: new Date().toISOString(),
406
778
  updatedAt: new Date().toISOString(),
407
779
  };
780
+ // Store secret in SecureCredential, not in the persisted webhook object (I321)
781
+ if (input.secret) {
782
+ this.webhookSecrets.set(webhook.id, new SecureCredential(input.secret, WEBHOOK_SECRET_TTL_MS));
783
+ }
408
784
  this.store.webhooks.push(webhook);
409
785
  this.saveStore();
410
786
  log.success(`✅ Webhook added: ${webhook.name}`);
787
+ await this.recordWebhookChange("add", webhook.id, null, validation.url.host);
411
788
  return webhook;
412
789
  }
413
790
  /**
414
- * Update a webhook
791
+ * Update a webhook. Re-validates the URL if it is being changed.
792
+ * Records a ChangeLog entry for SOC2 change-management audit trail.
415
793
  */
416
- updateWebhook(input) {
794
+ async updateWebhook(input) {
417
795
  const index = this.store.webhooks.findIndex((w) => w.id === input.id);
418
796
  if (index === -1)
419
797
  return null;
798
+ if (input.url !== undefined) {
799
+ const validation = await validateWebhookUrl(input.url);
800
+ if (!validation.ok) {
801
+ throw new Error(`webhook URL rejected: ${validation.error}`);
802
+ }
803
+ }
420
804
  const webhook = this.store.webhooks[index];
805
+ const oldHost = this.safeHost(webhook.url);
421
806
  const updated = {
422
807
  ...webhook,
423
808
  ...(input.name && { name: input.name }),
@@ -432,21 +817,59 @@ export class WebhookDispatcher {
432
817
  this.store.webhooks[index] = updated;
433
818
  this.saveStore();
434
819
  log.success(`✅ Webhook updated: ${updated.name}`);
820
+ await this.recordWebhookChange("update", updated.id, oldHost, this.safeHost(updated.url));
435
821
  return updated;
436
822
  }
437
823
  /**
438
- * Remove a webhook
824
+ * Remove a webhook.
825
+ * Records a ChangeLog entry for SOC2 change-management audit trail.
439
826
  */
440
- removeWebhook(id) {
827
+ async removeWebhook(id) {
441
828
  const index = this.store.webhooks.findIndex((w) => w.id === id);
442
829
  if (index === -1)
443
830
  return false;
444
831
  const webhook = this.store.webhooks[index];
445
832
  this.store.webhooks.splice(index, 1);
833
+ this.webhookSecrets.delete(id); // cleanup SecureCredential (I321)
834
+ this.circuitBreakers.delete(id);
446
835
  this.saveStore();
447
836
  log.success(`✅ Webhook removed: ${webhook.name}`);
837
+ await this.recordWebhookChange("remove", webhook.id, this.safeHost(webhook.url), null);
448
838
  return true;
449
839
  }
840
+ /**
841
+ * Helper: extract just the host from a URL for audit records. Never
842
+ * log the full URL (may contain secret tokens as path components, as
843
+ * Slack/Discord do).
844
+ */
845
+ safeHost(rawUrl) {
846
+ try {
847
+ return new URL(rawUrl).host;
848
+ }
849
+ catch (err) {
850
+ log.debug(`webhook-dispatcher: parsing URL in safeHost: ${err instanceof Error ? err.message : String(err)}`);
851
+ return "[invalid-url]";
852
+ }
853
+ }
854
+ /**
855
+ * Helper: write a ChangeLog entry for webhook CRUD. Errors are
856
+ * swallowed with a warning — the webhook change itself has already
857
+ * succeeded and compliance logging must not break the caller.
858
+ */
859
+ async recordWebhookChange(action, id, oldHost, newHost) {
860
+ try {
861
+ const { getChangeLog } = await import("../compliance/change-log.js");
862
+ await getChangeLog().recordChange("webhooks", `webhook.${id}`, oldHost, newHost, {
863
+ changedBy: "user",
864
+ method: "api",
865
+ impact: action === "remove" ? "medium" : "low",
866
+ affectedCompliance: ["SOC2"],
867
+ });
868
+ }
869
+ catch (err) {
870
+ log.warning(`ChangeLog recordChange failed (webhooks.${action}): ${err instanceof Error ? err.message : String(err)}`);
871
+ }
872
+ }
450
873
  /**
451
874
  * List all webhooks
452
875
  */
@@ -471,7 +894,7 @@ export class WebhookDispatcher {
471
894
  type: "question_answered",
472
895
  timestamp: new Date().toISOString(),
473
896
  source: "notebooklm-mcp",
474
- version: "1.7.0",
897
+ version: process.env.npm_package_version ?? "2026.2.11",
475
898
  payload: {
476
899
  question_length: 50,
477
900
  answer_length: 200,