@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.
- package/README.md +62 -19
- 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 +117 -44
- package/dist/auth/auth-manager.js.map +1 -1
- package/dist/auth/mcp-auth.d.ts +24 -4
- package/dist/auth/mcp-auth.d.ts.map +1 -1
- package/dist/auth/mcp-auth.js +149 -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 +24 -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 +8 -31
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -64
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +22 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +55 -4
- 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 +412 -89
- 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 +20 -21
- 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 -1
- package/dist/notebook-creation/index.d.ts.map +1 -1
- package/dist/notebook-creation/index.js +2 -1
- 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 +240 -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 +23 -37
- package/dist/notebook-creation/selectors.d.ts.map +1 -1
- package/dist/notebook-creation/selectors.js +56 -60
- package/dist/notebook-creation/selectors.js.map +1 -1
- package/dist/notebook-creation/source-manager.d.ts +25 -0
- package/dist/notebook-creation/source-manager.d.ts.map +1 -1
- package/dist/notebook-creation/source-manager.js +689 -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 +33 -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 +39 -17
- 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.d.ts.map +1 -1
- package/dist/tools/annotations.js +9 -56
- 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 +54 -11
- 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 +4 -1
- 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 +57 -13
- 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 +22 -161
- 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 +16 -0
- package/dist/tools/handlers/error-utils.d.ts.map +1 -0
- package/dist/tools/handlers/error-utils.js +39 -0
- package/dist/tools/handlers/error-utils.js.map +1 -0
- package/dist/tools/handlers/gemini.d.ts +2 -0
- package/dist/tools/handlers/gemini.d.ts.map +1 -1
- package/dist/tools/handlers/gemini.js +88 -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 +15 -4
- 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 +102 -86
- 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 +8 -10
- package/dist/tools/handlers/session-management.d.ts.map +1 -1
- package/dist/tools/handlers/session-management.js +34 -63
- 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 +198 -30
- 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 +87 -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 +13 -0
- package/dist/utils/page-utils.d.ts.map +1 -1
- package/dist/utils/page-utils.js +61 -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 +65 -17
- 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 +43 -13
- 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 +4 -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 +497 -74
- 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 +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.
|
|
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,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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
*
|
|
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:
|
|
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: "
|
|
897
|
+
version: process.env.npm_package_version ?? "2026.2.11",
|
|
475
898
|
payload: {
|
|
476
899
|
question_length: 50,
|
|
477
900
|
answer_length: 200,
|