@pan-sec/notebooklm-mcp 2026.2.10 → 2026.3.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 (302) hide show
  1. package/README.md +71 -27
  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 +97 -42
  6. package/dist/auth/auth-manager.js.map +1 -1
  7. package/dist/auth/mcp-auth.d.ts +22 -4
  8. package/dist/auth/mcp-auth.d.ts.map +1 -1
  9. package/dist/auth/mcp-auth.js +120 -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 +18 -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 +4 -31
  68. package/dist/config.d.ts.map +1 -1
  69. package/dist/config.js +25 -63
  70. package/dist/config.js.map +1 -1
  71. package/dist/errors.d.ts +21 -0
  72. package/dist/errors.d.ts.map +1 -1
  73. package/dist/errors.js +54 -1
  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 +399 -85
  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 +21 -22
  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 -0
  122. package/dist/notebook-creation/index.d.ts.map +1 -1
  123. package/dist/notebook-creation/index.js +2 -0
  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 +239 -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 +26 -21
  140. package/dist/notebook-creation/selectors.d.ts.map +1 -1
  141. package/dist/notebook-creation/selectors.js +79 -36
  142. package/dist/notebook-creation/selectors.js.map +1 -1
  143. package/dist/notebook-creation/source-manager.d.ts +22 -0
  144. package/dist/notebook-creation/source-manager.d.ts.map +1 -1
  145. package/dist/notebook-creation/source-manager.js +716 -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 +45 -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 +29 -12
  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.js +9 -9
  173. package/dist/tools/annotations.js.map +1 -1
  174. package/dist/tools/definitions/ask-question.d.ts.map +1 -1
  175. package/dist/tools/definitions/ask-question.js +35 -100
  176. package/dist/tools/definitions/ask-question.js.map +1 -1
  177. package/dist/tools/definitions/chat-history.d.ts +47 -1
  178. package/dist/tools/definitions/chat-history.d.ts.map +1 -1
  179. package/dist/tools/definitions/chat-history.js +10 -1
  180. package/dist/tools/definitions/chat-history.js.map +1 -1
  181. package/dist/tools/definitions/data-tables.d.ts.map +1 -1
  182. package/dist/tools/definitions/data-tables.js +2 -0
  183. package/dist/tools/definitions/data-tables.js.map +1 -1
  184. package/dist/tools/definitions/gemini.d.ts.map +1 -1
  185. package/dist/tools/definitions/gemini.js +40 -10
  186. package/dist/tools/definitions/gemini.js.map +1 -1
  187. package/dist/tools/definitions/notebook-management.d.ts.map +1 -1
  188. package/dist/tools/definitions/notebook-management.js +100 -70
  189. package/dist/tools/definitions/notebook-management.js.map +1 -1
  190. package/dist/tools/definitions/query-history.d.ts +47 -1
  191. package/dist/tools/definitions/query-history.d.ts.map +1 -1
  192. package/dist/tools/definitions/query-history.js +7 -0
  193. package/dist/tools/definitions/query-history.js.map +1 -1
  194. package/dist/tools/definitions/session-management.d.ts.map +1 -1
  195. package/dist/tools/definitions/session-management.js +5 -0
  196. package/dist/tools/definitions/session-management.js.map +1 -1
  197. package/dist/tools/definitions/system.d.ts.map +1 -1
  198. package/dist/tools/definitions/system.js +71 -100
  199. package/dist/tools/definitions/system.js.map +1 -1
  200. package/dist/tools/definitions/video.d.ts.map +1 -1
  201. package/dist/tools/definitions/video.js +3 -0
  202. package/dist/tools/definitions/video.js.map +1 -1
  203. package/dist/tools/definitions.d.ts.map +1 -1
  204. package/dist/tools/definitions.js +4 -0
  205. package/dist/tools/definitions.js.map +1 -1
  206. package/dist/tools/handlers/ask-question.d.ts +1 -1
  207. package/dist/tools/handlers/ask-question.d.ts.map +1 -1
  208. package/dist/tools/handlers/ask-question.js +56 -12
  209. package/dist/tools/handlers/ask-question.js.map +1 -1
  210. package/dist/tools/handlers/audio-video.d.ts.map +1 -1
  211. package/dist/tools/handlers/audio-video.js +15 -7
  212. package/dist/tools/handlers/audio-video.js.map +1 -1
  213. package/dist/tools/handlers/auth.d.ts +14 -19
  214. package/dist/tools/handlers/auth.d.ts.map +1 -1
  215. package/dist/tools/handlers/auth.js +77 -121
  216. package/dist/tools/handlers/auth.js.map +1 -1
  217. package/dist/tools/handlers/error-utils.d.ts +7 -0
  218. package/dist/tools/handlers/error-utils.d.ts.map +1 -0
  219. package/dist/tools/handlers/error-utils.js +17 -0
  220. package/dist/tools/handlers/error-utils.js.map +1 -0
  221. package/dist/tools/handlers/gemini.d.ts +1 -0
  222. package/dist/tools/handlers/gemini.d.ts.map +1 -1
  223. package/dist/tools/handlers/gemini.js +81 -51
  224. package/dist/tools/handlers/gemini.js.map +1 -1
  225. package/dist/tools/handlers/index.d.ts +39 -47
  226. package/dist/tools/handlers/index.d.ts.map +1 -1
  227. package/dist/tools/handlers/index.js +13 -2
  228. package/dist/tools/handlers/index.js.map +1 -1
  229. package/dist/tools/handlers/notebook-creation.d.ts.map +1 -1
  230. package/dist/tools/handlers/notebook-creation.js +99 -20
  231. package/dist/tools/handlers/notebook-creation.js.map +1 -1
  232. package/dist/tools/handlers/notebook-management.d.ts +8 -8
  233. package/dist/tools/handlers/notebook-management.d.ts.map +1 -1
  234. package/dist/tools/handlers/notebook-management.js +34 -80
  235. package/dist/tools/handlers/notebook-management.js.map +1 -1
  236. package/dist/tools/handlers/session-management.d.ts.map +1 -1
  237. package/dist/tools/handlers/session-management.js +12 -5
  238. package/dist/tools/handlers/session-management.js.map +1 -1
  239. package/dist/tools/handlers/system.d.ts.map +1 -1
  240. package/dist/tools/handlers/system.js +45 -10
  241. package/dist/tools/handlers/system.js.map +1 -1
  242. package/dist/tools/handlers/types.d.ts +1 -1
  243. package/dist/tools/handlers/types.d.ts.map +1 -1
  244. package/dist/tools/handlers/webhooks.d.ts.map +1 -1
  245. package/dist/tools/handlers/webhooks.js +15 -13
  246. package/dist/tools/handlers/webhooks.js.map +1 -1
  247. package/dist/types.d.ts +7 -17
  248. package/dist/types.d.ts.map +1 -1
  249. package/dist/utils/audit-logger.d.ts +19 -1
  250. package/dist/utils/audit-logger.d.ts.map +1 -1
  251. package/dist/utils/audit-logger.js +193 -27
  252. package/dist/utils/audit-logger.js.map +1 -1
  253. package/dist/utils/cleanup-manager.d.ts.map +1 -1
  254. package/dist/utils/cleanup-manager.js +6 -3
  255. package/dist/utils/cleanup-manager.js.map +1 -1
  256. package/dist/utils/crypto.d.ts +4 -1
  257. package/dist/utils/crypto.d.ts.map +1 -1
  258. package/dist/utils/crypto.js +32 -21
  259. package/dist/utils/crypto.js.map +1 -1
  260. package/dist/utils/file-lock.d.ts.map +1 -1
  261. package/dist/utils/file-lock.js +80 -16
  262. package/dist/utils/file-lock.js.map +1 -1
  263. package/dist/utils/file-permissions.d.ts +2 -0
  264. package/dist/utils/file-permissions.d.ts.map +1 -1
  265. package/dist/utils/file-permissions.js +2 -1
  266. package/dist/utils/file-permissions.js.map +1 -1
  267. package/dist/utils/logger.d.ts +4 -0
  268. package/dist/utils/logger.d.ts.map +1 -1
  269. package/dist/utils/logger.js +16 -0
  270. package/dist/utils/logger.js.map +1 -1
  271. package/dist/utils/page-utils.d.ts.map +1 -1
  272. package/dist/utils/page-utils.js +22 -39
  273. package/dist/utils/page-utils.js.map +1 -1
  274. package/dist/utils/response-validator.d.ts.map +1 -1
  275. package/dist/utils/response-validator.js +27 -22
  276. package/dist/utils/response-validator.js.map +1 -1
  277. package/dist/utils/secrets-scanner.d.ts +11 -0
  278. package/dist/utils/secrets-scanner.d.ts.map +1 -1
  279. package/dist/utils/secrets-scanner.js +63 -15
  280. package/dist/utils/secrets-scanner.js.map +1 -1
  281. package/dist/utils/secure-memory.d.ts +9 -31
  282. package/dist/utils/secure-memory.d.ts.map +1 -1
  283. package/dist/utils/secure-memory.js +17 -102
  284. package/dist/utils/secure-memory.js.map +1 -1
  285. package/dist/utils/security.d.ts +4 -3
  286. package/dist/utils/security.d.ts.map +1 -1
  287. package/dist/utils/security.js +41 -11
  288. package/dist/utils/security.js.map +1 -1
  289. package/dist/utils/stealth-utils.d.ts.map +1 -1
  290. package/dist/utils/stealth-utils.js +4 -4
  291. package/dist/utils/stealth-utils.js.map +1 -1
  292. package/dist/webhooks/types.d.ts +2 -0
  293. package/dist/webhooks/types.d.ts.map +1 -1
  294. package/dist/webhooks/webhook-dispatcher.d.ts +80 -12
  295. package/dist/webhooks/webhook-dispatcher.d.ts.map +1 -1
  296. package/dist/webhooks/webhook-dispatcher.js +472 -72
  297. package/dist/webhooks/webhook-dispatcher.js.map +1 -1
  298. package/docs/archive/ISSUES-legacy-2026-04-24.md +644 -0
  299. package/docs/dependency-risk.md +25 -0
  300. package/docs/testing-runbook.md +166 -0
  301. package/docs/usage-guide.md +2 -1
  302. package/package.json +33 -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,148 @@ 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
+ durationMs: delivery.durationMs,
379
+ })}`);
380
+ }
126
381
  /**
127
- * Send event with retry logic
382
+ * Send event with retry logic.
383
+ *
384
+ * Retries capped at 3 attempts with max 30 s total window (I276).
385
+ * Payload is secrets-scanned before dispatch (I273).
386
+ * Outbound headers filtered to remove dangerous overrides (I282).
387
+ * HMAC signature includes unix timestamp to prevent replay (I271).
128
388
  */
129
389
  async sendWithRetry(webhook, event) {
130
- const maxAttempts = webhook.retryCount ?? 3;
131
- const baseDelay = webhook.retryDelayMs ?? 1000;
132
- const timeout = webhook.timeoutMs ?? 5000;
390
+ if (this.shouldSkipForOpenCircuit(webhook)) {
391
+ return false;
392
+ }
393
+ // Cap retries: max 3 attempts, max 10 s per-request timeout (I276)
394
+ const maxAttempts = Math.min(webhook.retryCount ?? 3, 3);
395
+ const baseDelay = Math.min(webhook.retryDelayMs ?? 1000, 2000);
396
+ const timeout = Math.min(webhook.timeoutMs ?? 5000, 10000);
133
397
  const deliveryId = crypto.randomUUID();
134
398
  const startTime = Date.now();
399
+ // Scan payload for secrets once before any attempt (I273)
400
+ const rawPayload = this.formatPayload(event, webhook.format);
401
+ let payload;
402
+ try {
403
+ const { clean } = await scanAndRedactSecrets(rawPayload);
404
+ payload = clean;
405
+ }
406
+ catch (err) {
407
+ log.debug(`webhook-dispatcher: scanning and redacting secrets from payload: ${err instanceof Error ? err.message : String(err)}`);
408
+ payload = rawPayload;
409
+ }
410
+ // Filter user-configured headers to block dangerous overrides (I282)
411
+ const safeCustomHeaders = Object.fromEntries(Object.entries(webhook.headers ?? {}).filter(([k]) => !BLOCKED_OUTBOUND_HEADERS.has(k.toLowerCase())));
135
412
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
136
413
  try {
137
- const payload = this.formatPayload(event, webhook.format);
138
- const signature = webhook.secret
139
- ? this.sign(payload, webhook.secret)
414
+ // Include unix timestamp in HMAC to prevent indefinite replay (I271)
415
+ const timestamp = Math.floor(Date.now() / 1000);
416
+ const secret = this.webhookSecrets.get(webhook.id)?.getValue() ?? webhook.secret;
417
+ const signature = secret
418
+ ? this.sign(payload, secret, timestamp)
140
419
  : undefined;
141
420
  const controller = new AbortController();
142
421
  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);
422
+ let response;
423
+ try {
424
+ response = await fetch(webhook.url, {
425
+ method: "POST",
426
+ headers: {
427
+ "Content-Type": "application/json",
428
+ "User-Agent": `notebooklm-mcp/${process.env.npm_package_version ?? "2026.2.11"}`,
429
+ ...(signature && {
430
+ "X-Webhook-Signature": signature,
431
+ "X-Webhook-Timestamp": String(timestamp),
432
+ }),
433
+ ...safeCustomHeaders,
434
+ },
435
+ body: payload,
436
+ signal: controller.signal,
437
+ // Refuse redirects — a pre-validated host redirecting to cloud
438
+ // metadata (169.254.169.254) would otherwise bypass validateWebhookUrl.
439
+ redirect: "error",
440
+ });
441
+ }
442
+ finally {
443
+ clearTimeout(timeoutId);
444
+ }
155
445
  const delivery = {
156
- id: deliveryId,
446
+ id: `${deliveryId}-${attempt}`,
447
+ sequence: this.nextDeliverySequence(),
157
448
  webhookId: webhook.id,
158
449
  eventType: event.type,
159
450
  timestamp: new Date().toISOString(),
@@ -163,7 +454,9 @@ export class WebhookDispatcher {
163
454
  durationMs: Date.now() - startTime,
164
455
  };
165
456
  this.recordDelivery(delivery);
457
+ this.logAttempt(webhook, event, attempt, maxAttempts, delivery);
166
458
  if (response.ok) {
459
+ this.onDeliverySuccess(webhook);
167
460
  log.dim(` ✅ Webhook delivered: ${webhook.name} (${event.type})`);
168
461
  return true;
169
462
  }
@@ -171,18 +464,21 @@ export class WebhookDispatcher {
171
464
  }
172
465
  catch (error) {
173
466
  const errorMessage = error instanceof Error ? error.message : String(error);
467
+ const delivery = {
468
+ id: `${deliveryId}-${attempt}`,
469
+ sequence: this.nextDeliverySequence(),
470
+ webhookId: webhook.id,
471
+ eventType: event.type,
472
+ timestamp: new Date().toISOString(),
473
+ success: false,
474
+ error: errorMessage,
475
+ attempts: attempt,
476
+ durationMs: Date.now() - startTime,
477
+ };
478
+ this.recordDelivery(delivery);
479
+ this.logAttempt(webhook, event, attempt, maxAttempts, delivery);
174
480
  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);
481
+ this.onDeliveryFailure(webhook);
186
482
  log.error(` ❌ Webhook failed permanently: ${webhook.name} - ${errorMessage}`);
187
483
  return false;
188
484
  }
@@ -194,6 +490,7 @@ export class WebhookDispatcher {
194
490
  await new Promise((r) => setTimeout(r, delay));
195
491
  }
196
492
  }
493
+ this.onDeliveryFailure(webhook);
197
494
  return false;
198
495
  }
199
496
  /**
@@ -369,27 +666,79 @@ export class WebhookDispatcher {
369
666
  }
370
667
  }
371
668
  /**
372
- * Sign payload with HMAC-SHA256
669
+ * Sign payload with HMAC-SHA256, including a unix timestamp in the signed
670
+ * data so receivers can reject replayed requests (I271).
671
+ * Signed message: "<timestamp>\n<payload>"
373
672
  */
374
- sign(payload, secret) {
673
+ sign(payload, secret, timestamp) {
375
674
  const hmac = crypto.createHmac("sha256", secret);
376
- hmac.update(payload);
675
+ hmac.update(`${timestamp}\n${payload}`);
377
676
  return `sha256=${hmac.digest("hex")}`;
378
677
  }
379
678
  /**
380
- * Record delivery for history
679
+ * Load recent delivery history from disk on startup (I279)
680
+ */
681
+ loadDeliveryHistory() {
682
+ try {
683
+ if (!fs.existsSync(this.deliveryLogPath))
684
+ return;
685
+ const lines = fs.readFileSync(this.deliveryLogPath, "utf-8").trim().split("\n");
686
+ // Load last maxDeliveryHistory lines to seed in-memory buffer
687
+ const recent = lines.slice(-this.maxDeliveryHistory);
688
+ for (const line of recent) {
689
+ try {
690
+ if (line.trim()) {
691
+ const delivery = JSON.parse(line);
692
+ this.deliveryHistory.push(delivery);
693
+ if (typeof delivery.sequence === "number" && delivery.sequence > this.deliverySequence) {
694
+ this.deliverySequence = delivery.sequence;
695
+ }
696
+ }
697
+ }
698
+ catch {
699
+ // skip malformed lines
700
+ }
701
+ }
702
+ }
703
+ catch (err) {
704
+ log.debug(`webhook-dispatcher: failed to load delivery history: ${err instanceof Error ? err.message : String(err)}`);
705
+ }
706
+ }
707
+ /**
708
+ * Record delivery for history — persists to disk for cross-restart auditability (I279)
381
709
  */
382
710
  recordDelivery(delivery) {
711
+ getMetricsRegistry().increment("webhook_deliveries_total", {
712
+ event_type: delivery.eventType,
713
+ success: delivery.success,
714
+ });
383
715
  this.deliveryHistory.push(delivery);
384
716
  if (this.deliveryHistory.length > this.maxDeliveryHistory) {
385
717
  this.deliveryHistory.shift();
386
718
  }
719
+ // Append to delivery log for durable audit trail
720
+ try {
721
+ fs.appendFileSync(this.deliveryLogPath, JSON.stringify(delivery) + "\n", { mode: 0o600 });
722
+ }
723
+ catch (err) {
724
+ log.debug(`webhook-dispatcher: failed to persist delivery record: ${err instanceof Error ? err.message : String(err)}`);
725
+ }
726
+ }
727
+ nextDeliverySequence() {
728
+ this.deliverySequence += 1;
729
+ return this.deliverySequence;
387
730
  }
388
731
  // === Public API ===
389
732
  /**
390
- * Add a new webhook
733
+ * Add a new webhook. Validates the URL before persisting; throws on
734
+ * scheme/host/DNS-resolution failure so callers see a clear reason.
735
+ * Records a ChangeLog entry for SOC2 change-management audit trail.
391
736
  */
392
- addWebhook(input) {
737
+ async addWebhook(input) {
738
+ const validation = await validateWebhookUrl(input.url);
739
+ if (!validation.ok) {
740
+ throw new Error(`webhook URL rejected: ${validation.error}`);
741
+ }
393
742
  const webhook = {
394
743
  id: crypto.randomUUID(),
395
744
  name: input.name,
@@ -397,7 +746,7 @@ export class WebhookDispatcher {
397
746
  enabled: true,
398
747
  events: input.events || ["*"],
399
748
  format: input.format || "generic",
400
- secret: input.secret,
749
+ secret: undefined, // secret never persisted to disk
401
750
  headers: input.headers,
402
751
  retryCount: 3,
403
752
  retryDelayMs: 1000,
@@ -405,19 +754,32 @@ export class WebhookDispatcher {
405
754
  createdAt: new Date().toISOString(),
406
755
  updatedAt: new Date().toISOString(),
407
756
  };
757
+ // Store secret in SecureCredential, not in the persisted webhook object (I321)
758
+ if (input.secret) {
759
+ this.webhookSecrets.set(webhook.id, new SecureCredential(input.secret, WEBHOOK_SECRET_TTL_MS));
760
+ }
408
761
  this.store.webhooks.push(webhook);
409
762
  this.saveStore();
410
763
  log.success(`✅ Webhook added: ${webhook.name}`);
764
+ await this.recordWebhookChange("add", webhook.id, null, validation.url.host);
411
765
  return webhook;
412
766
  }
413
767
  /**
414
- * Update a webhook
768
+ * Update a webhook. Re-validates the URL if it is being changed.
769
+ * Records a ChangeLog entry for SOC2 change-management audit trail.
415
770
  */
416
- updateWebhook(input) {
771
+ async updateWebhook(input) {
417
772
  const index = this.store.webhooks.findIndex((w) => w.id === input.id);
418
773
  if (index === -1)
419
774
  return null;
775
+ if (input.url !== undefined) {
776
+ const validation = await validateWebhookUrl(input.url);
777
+ if (!validation.ok) {
778
+ throw new Error(`webhook URL rejected: ${validation.error}`);
779
+ }
780
+ }
420
781
  const webhook = this.store.webhooks[index];
782
+ const oldHost = this.safeHost(webhook.url);
421
783
  const updated = {
422
784
  ...webhook,
423
785
  ...(input.name && { name: input.name }),
@@ -432,21 +794,59 @@ export class WebhookDispatcher {
432
794
  this.store.webhooks[index] = updated;
433
795
  this.saveStore();
434
796
  log.success(`✅ Webhook updated: ${updated.name}`);
797
+ await this.recordWebhookChange("update", updated.id, oldHost, this.safeHost(updated.url));
435
798
  return updated;
436
799
  }
437
800
  /**
438
- * Remove a webhook
801
+ * Remove a webhook.
802
+ * Records a ChangeLog entry for SOC2 change-management audit trail.
439
803
  */
440
- removeWebhook(id) {
804
+ async removeWebhook(id) {
441
805
  const index = this.store.webhooks.findIndex((w) => w.id === id);
442
806
  if (index === -1)
443
807
  return false;
444
808
  const webhook = this.store.webhooks[index];
445
809
  this.store.webhooks.splice(index, 1);
810
+ this.webhookSecrets.delete(id); // cleanup SecureCredential (I321)
811
+ this.circuitBreakers.delete(id);
446
812
  this.saveStore();
447
813
  log.success(`✅ Webhook removed: ${webhook.name}`);
814
+ await this.recordWebhookChange("remove", webhook.id, this.safeHost(webhook.url), null);
448
815
  return true;
449
816
  }
817
+ /**
818
+ * Helper: extract just the host from a URL for audit records. Never
819
+ * log the full URL (may contain secret tokens as path components, as
820
+ * Slack/Discord do).
821
+ */
822
+ safeHost(rawUrl) {
823
+ try {
824
+ return new URL(rawUrl).host;
825
+ }
826
+ catch (err) {
827
+ log.debug(`webhook-dispatcher: parsing URL in safeHost: ${err instanceof Error ? err.message : String(err)}`);
828
+ return "[invalid-url]";
829
+ }
830
+ }
831
+ /**
832
+ * Helper: write a ChangeLog entry for webhook CRUD. Errors are
833
+ * swallowed with a warning — the webhook change itself has already
834
+ * succeeded and compliance logging must not break the caller.
835
+ */
836
+ async recordWebhookChange(action, id, oldHost, newHost) {
837
+ try {
838
+ const { getChangeLog } = await import("../compliance/change-log.js");
839
+ await getChangeLog().recordChange("webhooks", `webhook.${id}`, oldHost, newHost, {
840
+ changedBy: "user",
841
+ method: "api",
842
+ impact: action === "remove" ? "medium" : "low",
843
+ affectedCompliance: ["SOC2"],
844
+ });
845
+ }
846
+ catch (err) {
847
+ log.warning(`ChangeLog recordChange failed (webhooks.${action}): ${err instanceof Error ? err.message : String(err)}`);
848
+ }
849
+ }
450
850
  /**
451
851
  * List all webhooks
452
852
  */
@@ -471,7 +871,7 @@ export class WebhookDispatcher {
471
871
  type: "question_answered",
472
872
  timestamp: new Date().toISOString(),
473
873
  source: "notebooklm-mcp",
474
- version: "1.7.0",
874
+ version: process.env.npm_package_version ?? "2026.2.11",
475
875
  payload: {
476
876
  question_length: 50,
477
877
  answer_length: 200,