@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.
- package/README.md +71 -27
- package/SECURITY.md +31 -61
- package/dist/auth/auth-manager.d.ts +2 -1
- package/dist/auth/auth-manager.d.ts.map +1 -1
- package/dist/auth/auth-manager.js +97 -42
- package/dist/auth/auth-manager.js.map +1 -1
- package/dist/auth/mcp-auth.d.ts +22 -4
- package/dist/auth/mcp-auth.d.ts.map +1 -1
- package/dist/auth/mcp-auth.js +120 -19
- package/dist/auth/mcp-auth.js.map +1 -1
- package/dist/compliance/alert-manager.d.ts.map +1 -1
- package/dist/compliance/alert-manager.js +7 -4
- package/dist/compliance/alert-manager.js.map +1 -1
- package/dist/compliance/breach-detection.d.ts.map +1 -1
- package/dist/compliance/breach-detection.js +14 -7
- package/dist/compliance/breach-detection.js.map +1 -1
- package/dist/compliance/change-log.d.ts.map +1 -1
- package/dist/compliance/change-log.js +7 -4
- package/dist/compliance/change-log.js.map +1 -1
- package/dist/compliance/compliance-logger.d.ts.map +1 -1
- package/dist/compliance/compliance-logger.js +11 -6
- package/dist/compliance/compliance-logger.js.map +1 -1
- package/dist/compliance/consent-manager.d.ts.map +1 -1
- package/dist/compliance/consent-manager.js +5 -3
- package/dist/compliance/consent-manager.js.map +1 -1
- package/dist/compliance/data-erasure.d.ts +1 -1
- package/dist/compliance/data-erasure.d.ts.map +1 -1
- package/dist/compliance/data-erasure.js +142 -83
- package/dist/compliance/data-erasure.js.map +1 -1
- package/dist/compliance/data-export.d.ts.map +1 -1
- package/dist/compliance/data-export.js +23 -12
- package/dist/compliance/data-export.js.map +1 -1
- package/dist/compliance/data-inventory.d.ts.map +1 -1
- package/dist/compliance/data-inventory.js +7 -6
- package/dist/compliance/data-inventory.js.map +1 -1
- package/dist/compliance/dsar-handler.d.ts +7 -1
- package/dist/compliance/dsar-handler.d.ts.map +1 -1
- package/dist/compliance/dsar-handler.js +74 -61
- package/dist/compliance/dsar-handler.js.map +1 -1
- package/dist/compliance/evidence-collector.d.ts.map +1 -1
- package/dist/compliance/evidence-collector.js +10 -6
- package/dist/compliance/evidence-collector.js.map +1 -1
- package/dist/compliance/health-monitor.d.ts.map +1 -1
- package/dist/compliance/health-monitor.js +15 -9
- package/dist/compliance/health-monitor.js.map +1 -1
- package/dist/compliance/incident-manager.d.ts.map +1 -1
- package/dist/compliance/incident-manager.js +5 -3
- package/dist/compliance/incident-manager.js.map +1 -1
- package/dist/compliance/policy-docs.d.ts.map +1 -1
- package/dist/compliance/policy-docs.js +14 -11
- package/dist/compliance/policy-docs.js.map +1 -1
- package/dist/compliance/privacy-notice-text.d.ts.map +1 -1
- package/dist/compliance/privacy-notice-text.js +3 -4
- package/dist/compliance/privacy-notice-text.js.map +1 -1
- package/dist/compliance/privacy-notice.d.ts.map +1 -1
- package/dist/compliance/privacy-notice.js +5 -3
- package/dist/compliance/privacy-notice.js.map +1 -1
- package/dist/compliance/report-generator.d.ts.map +1 -1
- package/dist/compliance/report-generator.js +5 -3
- package/dist/compliance/report-generator.js.map +1 -1
- package/dist/compliance/retention-engine.d.ts.map +1 -1
- package/dist/compliance/retention-engine.js +18 -10
- package/dist/compliance/retention-engine.js.map +1 -1
- package/dist/compliance/siem-exporter.d.ts.map +1 -1
- package/dist/compliance/siem-exporter.js +40 -16
- package/dist/compliance/siem-exporter.js.map +1 -1
- package/dist/config.d.ts +4 -31
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +25 -63
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +54 -1
- package/dist/errors.js.map +1 -1
- package/dist/gemini/gemini-client.d.ts +1 -0
- package/dist/gemini/gemini-client.d.ts.map +1 -1
- package/dist/gemini/gemini-client.js +50 -49
- package/dist/gemini/gemini-client.js.map +1 -1
- package/dist/gemini/types.d.ts +3 -1
- package/dist/gemini/types.d.ts.map +1 -1
- package/dist/gemini/types.js.map +1 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +399 -85
- package/dist/index.js.map +1 -1
- package/dist/library/notebook-library.d.ts.map +1 -1
- package/dist/library/notebook-library.js +2 -1
- package/dist/library/notebook-library.js.map +1 -1
- package/dist/logging/query-logger.d.ts +13 -1
- package/dist/logging/query-logger.d.ts.map +1 -1
- package/dist/logging/query-logger.js +62 -10
- package/dist/logging/query-logger.js.map +1 -1
- package/dist/notebook-creation/audio-manager.d.ts.map +1 -1
- package/dist/notebook-creation/audio-manager.js +19 -24
- package/dist/notebook-creation/audio-manager.js.map +1 -1
- package/dist/notebook-creation/browser-options.d.ts +28 -0
- package/dist/notebook-creation/browser-options.d.ts.map +1 -0
- package/dist/notebook-creation/browser-options.js +75 -0
- package/dist/notebook-creation/browser-options.js.map +1 -0
- package/dist/notebook-creation/data-table-manager.d.ts.map +1 -1
- package/dist/notebook-creation/data-table-manager.js +21 -22
- package/dist/notebook-creation/data-table-manager.js.map +1 -1
- package/dist/notebook-creation/discover-creation-flow.d.ts +0 -6
- package/dist/notebook-creation/discover-creation-flow.d.ts.map +1 -1
- package/dist/notebook-creation/discover-creation-flow.js +10 -10
- package/dist/notebook-creation/discover-creation-flow.js.map +1 -1
- package/dist/notebook-creation/discover-quota.d.ts +0 -6
- package/dist/notebook-creation/discover-quota.d.ts.map +1 -1
- package/dist/notebook-creation/discover-quota.js +12 -13
- package/dist/notebook-creation/discover-quota.js.map +1 -1
- package/dist/notebook-creation/discover-sources.js +15 -16
- package/dist/notebook-creation/discover-sources.js.map +1 -1
- package/dist/notebook-creation/dom-scripts.d.ts +10 -0
- package/dist/notebook-creation/dom-scripts.d.ts.map +1 -0
- package/dist/notebook-creation/dom-scripts.js +58 -0
- package/dist/notebook-creation/dom-scripts.js.map +1 -0
- package/dist/notebook-creation/errors.d.ts +18 -0
- package/dist/notebook-creation/errors.d.ts.map +1 -0
- package/dist/notebook-creation/errors.js +20 -0
- package/dist/notebook-creation/errors.js.map +1 -0
- package/dist/notebook-creation/index.d.ts +2 -0
- package/dist/notebook-creation/index.d.ts.map +1 -1
- package/dist/notebook-creation/index.js +2 -0
- package/dist/notebook-creation/index.js.map +1 -1
- package/dist/notebook-creation/notebook-creator.d.ts +6 -82
- package/dist/notebook-creation/notebook-creator.d.ts.map +1 -1
- package/dist/notebook-creation/notebook-creator.js +49 -835
- package/dist/notebook-creation/notebook-creator.js.map +1 -1
- package/dist/notebook-creation/notebook-nav.d.ts +19 -0
- package/dist/notebook-creation/notebook-nav.d.ts.map +1 -0
- package/dist/notebook-creation/notebook-nav.js +239 -0
- package/dist/notebook-creation/notebook-nav.js.map +1 -0
- package/dist/notebook-creation/notebook-sync.d.ts.map +1 -1
- package/dist/notebook-creation/notebook-sync.js +36 -38
- package/dist/notebook-creation/notebook-sync.js.map +1 -1
- package/dist/notebook-creation/selector-discovery.d.ts.map +1 -1
- package/dist/notebook-creation/selector-discovery.js +17 -24
- package/dist/notebook-creation/selector-discovery.js.map +1 -1
- package/dist/notebook-creation/selectors.d.ts +26 -21
- package/dist/notebook-creation/selectors.d.ts.map +1 -1
- package/dist/notebook-creation/selectors.js +79 -36
- package/dist/notebook-creation/selectors.js.map +1 -1
- package/dist/notebook-creation/source-manager.d.ts +22 -0
- package/dist/notebook-creation/source-manager.d.ts.map +1 -1
- package/dist/notebook-creation/source-manager.js +716 -50
- package/dist/notebook-creation/source-manager.js.map +1 -1
- package/dist/notebook-creation/types.d.ts +4 -0
- package/dist/notebook-creation/types.d.ts.map +1 -1
- package/dist/notebook-creation/video-manager.d.ts.map +1 -1
- package/dist/notebook-creation/video-manager.js +45 -35
- package/dist/notebook-creation/video-manager.js.map +1 -1
- package/dist/observability/metrics.d.ts +19 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +35 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/quota/quota-manager.d.ts +11 -3
- package/dist/quota/quota-manager.d.ts.map +1 -1
- package/dist/quota/quota-manager.js +139 -47
- package/dist/quota/quota-manager.js.map +1 -1
- package/dist/resources/resource-handlers.d.ts.map +1 -1
- package/dist/resources/resource-handlers.js +29 -12
- package/dist/resources/resource-handlers.js.map +1 -1
- package/dist/session/browser-session.d.ts.map +1 -1
- package/dist/session/browser-session.js +22 -22
- package/dist/session/browser-session.js.map +1 -1
- package/dist/session/session-timeout.d.ts.map +1 -1
- package/dist/session/session-timeout.js +4 -2
- package/dist/session/session-timeout.js.map +1 -1
- package/dist/session/shared-context-manager.d.ts.map +1 -1
- package/dist/session/shared-context-manager.js +31 -30
- package/dist/session/shared-context-manager.js.map +1 -1
- package/dist/tools/annotations.js +9 -9
- package/dist/tools/annotations.js.map +1 -1
- package/dist/tools/definitions/ask-question.d.ts.map +1 -1
- package/dist/tools/definitions/ask-question.js +35 -100
- package/dist/tools/definitions/ask-question.js.map +1 -1
- package/dist/tools/definitions/chat-history.d.ts +47 -1
- package/dist/tools/definitions/chat-history.d.ts.map +1 -1
- package/dist/tools/definitions/chat-history.js +10 -1
- package/dist/tools/definitions/chat-history.js.map +1 -1
- package/dist/tools/definitions/data-tables.d.ts.map +1 -1
- package/dist/tools/definitions/data-tables.js +2 -0
- package/dist/tools/definitions/data-tables.js.map +1 -1
- package/dist/tools/definitions/gemini.d.ts.map +1 -1
- package/dist/tools/definitions/gemini.js +40 -10
- package/dist/tools/definitions/gemini.js.map +1 -1
- package/dist/tools/definitions/notebook-management.d.ts.map +1 -1
- package/dist/tools/definitions/notebook-management.js +100 -70
- package/dist/tools/definitions/notebook-management.js.map +1 -1
- package/dist/tools/definitions/query-history.d.ts +47 -1
- package/dist/tools/definitions/query-history.d.ts.map +1 -1
- package/dist/tools/definitions/query-history.js +7 -0
- package/dist/tools/definitions/query-history.js.map +1 -1
- package/dist/tools/definitions/session-management.d.ts.map +1 -1
- package/dist/tools/definitions/session-management.js +5 -0
- package/dist/tools/definitions/session-management.js.map +1 -1
- package/dist/tools/definitions/system.d.ts.map +1 -1
- package/dist/tools/definitions/system.js +71 -100
- package/dist/tools/definitions/system.js.map +1 -1
- package/dist/tools/definitions/video.d.ts.map +1 -1
- package/dist/tools/definitions/video.js +3 -0
- package/dist/tools/definitions/video.js.map +1 -1
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +4 -0
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/handlers/ask-question.d.ts +1 -1
- package/dist/tools/handlers/ask-question.d.ts.map +1 -1
- package/dist/tools/handlers/ask-question.js +56 -12
- package/dist/tools/handlers/ask-question.js.map +1 -1
- package/dist/tools/handlers/audio-video.d.ts.map +1 -1
- package/dist/tools/handlers/audio-video.js +15 -7
- package/dist/tools/handlers/audio-video.js.map +1 -1
- package/dist/tools/handlers/auth.d.ts +14 -19
- package/dist/tools/handlers/auth.d.ts.map +1 -1
- package/dist/tools/handlers/auth.js +77 -121
- package/dist/tools/handlers/auth.js.map +1 -1
- package/dist/tools/handlers/error-utils.d.ts +7 -0
- package/dist/tools/handlers/error-utils.d.ts.map +1 -0
- package/dist/tools/handlers/error-utils.js +17 -0
- package/dist/tools/handlers/error-utils.js.map +1 -0
- package/dist/tools/handlers/gemini.d.ts +1 -0
- package/dist/tools/handlers/gemini.d.ts.map +1 -1
- package/dist/tools/handlers/gemini.js +81 -51
- package/dist/tools/handlers/gemini.js.map +1 -1
- package/dist/tools/handlers/index.d.ts +39 -47
- package/dist/tools/handlers/index.d.ts.map +1 -1
- package/dist/tools/handlers/index.js +13 -2
- package/dist/tools/handlers/index.js.map +1 -1
- package/dist/tools/handlers/notebook-creation.d.ts.map +1 -1
- package/dist/tools/handlers/notebook-creation.js +99 -20
- package/dist/tools/handlers/notebook-creation.js.map +1 -1
- package/dist/tools/handlers/notebook-management.d.ts +8 -8
- package/dist/tools/handlers/notebook-management.d.ts.map +1 -1
- package/dist/tools/handlers/notebook-management.js +34 -80
- package/dist/tools/handlers/notebook-management.js.map +1 -1
- package/dist/tools/handlers/session-management.d.ts.map +1 -1
- package/dist/tools/handlers/session-management.js +12 -5
- package/dist/tools/handlers/session-management.js.map +1 -1
- package/dist/tools/handlers/system.d.ts.map +1 -1
- package/dist/tools/handlers/system.js +45 -10
- package/dist/tools/handlers/system.js.map +1 -1
- package/dist/tools/handlers/types.d.ts +1 -1
- package/dist/tools/handlers/types.d.ts.map +1 -1
- package/dist/tools/handlers/webhooks.d.ts.map +1 -1
- package/dist/tools/handlers/webhooks.js +15 -13
- package/dist/tools/handlers/webhooks.js.map +1 -1
- package/dist/types.d.ts +7 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/audit-logger.d.ts +19 -1
- package/dist/utils/audit-logger.d.ts.map +1 -1
- package/dist/utils/audit-logger.js +193 -27
- package/dist/utils/audit-logger.js.map +1 -1
- package/dist/utils/cleanup-manager.d.ts.map +1 -1
- package/dist/utils/cleanup-manager.js +6 -3
- package/dist/utils/cleanup-manager.js.map +1 -1
- package/dist/utils/crypto.d.ts +4 -1
- package/dist/utils/crypto.d.ts.map +1 -1
- package/dist/utils/crypto.js +32 -21
- package/dist/utils/crypto.js.map +1 -1
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +80 -16
- package/dist/utils/file-lock.js.map +1 -1
- package/dist/utils/file-permissions.d.ts +2 -0
- package/dist/utils/file-permissions.d.ts.map +1 -1
- package/dist/utils/file-permissions.js +2 -1
- package/dist/utils/file-permissions.js.map +1 -1
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/page-utils.d.ts.map +1 -1
- package/dist/utils/page-utils.js +22 -39
- package/dist/utils/page-utils.js.map +1 -1
- package/dist/utils/response-validator.d.ts.map +1 -1
- package/dist/utils/response-validator.js +27 -22
- package/dist/utils/response-validator.js.map +1 -1
- package/dist/utils/secrets-scanner.d.ts +11 -0
- package/dist/utils/secrets-scanner.d.ts.map +1 -1
- package/dist/utils/secrets-scanner.js +63 -15
- package/dist/utils/secrets-scanner.js.map +1 -1
- package/dist/utils/secure-memory.d.ts +9 -31
- package/dist/utils/secure-memory.d.ts.map +1 -1
- package/dist/utils/secure-memory.js +17 -102
- package/dist/utils/secure-memory.js.map +1 -1
- package/dist/utils/security.d.ts +4 -3
- package/dist/utils/security.d.ts.map +1 -1
- package/dist/utils/security.js +41 -11
- package/dist/utils/security.js.map +1 -1
- package/dist/utils/stealth-utils.d.ts.map +1 -1
- package/dist/utils/stealth-utils.js +4 -4
- package/dist/utils/stealth-utils.js.map +1 -1
- package/dist/webhooks/types.d.ts +2 -0
- package/dist/webhooks/types.d.ts.map +1 -1
- package/dist/webhooks/webhook-dispatcher.d.ts +80 -12
- package/dist/webhooks/webhook-dispatcher.d.ts.map +1 -1
- package/dist/webhooks/webhook-dispatcher.js +472 -72
- package/dist/webhooks/webhook-dispatcher.js.map +1 -1
- package/docs/archive/ISSUES-legacy-2026-04-24.md +644 -0
- package/docs/dependency-risk.md +25 -0
- package/docs/testing-runbook.md +166 -0
- package/docs/usage-guide.md +2 -1
- 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.
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
248
|
+
if (webhookUrl) {
|
|
65
249
|
const events = process.env.NLMCP_WEBHOOK_EVENTS
|
|
66
250
|
? process.env.NLMCP_WEBHOOK_EVENTS.split(",")
|
|
67
251
|
: ["*"];
|
|
68
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
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: "
|
|
874
|
+
version: process.env.npm_package_version ?? "2026.2.11",
|
|
475
875
|
payload: {
|
|
476
876
|
question_length: 50,
|
|
477
877
|
answer_length: 200,
|