@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21
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/package.json +2 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-host-supervisors-fallback.test.ts +262 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/db.ts +196 -0
- package/src/index.ts +216 -3
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +1776 -13
- package/src/update-provider.test.ts +234 -0
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// RFC-028 P1 §4.4 probe-validate tests — pure unit, no I/O.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
validateBaseUrl, isForbiddenIp, isLoopbackHost, assertSecureTlsEnv,
|
|
6
|
+
ProbeAckPayloadSchema, deriveErrorLabel, rejectIfSecretLeaked,
|
|
7
|
+
VENDOR_HOST_ALLOWLIST, SUPPORTED_VENDORS, ProbeValidationError,
|
|
8
|
+
} from "./probe-validate.js";
|
|
9
|
+
|
|
10
|
+
describe("validateBaseUrl (§4.4.1 vendor host allowlist)", () => {
|
|
11
|
+
test("anthropic + api.anthropic.com → ok", () => {
|
|
12
|
+
expect(() => validateBaseUrl("anthropic", "https://api.anthropic.com/v1")).not.toThrow();
|
|
13
|
+
});
|
|
14
|
+
test("anthropic + api.openai.com → probe_target_forbidden", () => {
|
|
15
|
+
expect(() => validateBaseUrl("anthropic", "https://api.openai.com/v1")).toThrow(/probe_target_forbidden/);
|
|
16
|
+
});
|
|
17
|
+
test("anthropic + 169.254.169.254 (metadata) → probe_target_forbidden (host不在 allowlist)", () => {
|
|
18
|
+
expect(() => validateBaseUrl("anthropic", "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
|
|
19
|
+
});
|
|
20
|
+
test("unsupported vendor → vendor_not_supported", () => {
|
|
21
|
+
expect(() => validateBaseUrl("openai", "https://api.openai.com/v1")).toThrow(/vendor_not_supported/);
|
|
22
|
+
});
|
|
23
|
+
test("non-https → probe_base_url_invalid", () => {
|
|
24
|
+
expect(() => validateBaseUrl("anthropic", "http://api.anthropic.com/v1")).toThrow(/probe_base_url_invalid/);
|
|
25
|
+
});
|
|
26
|
+
test("http+loopback OK with allowLoopback opt-in", () => {
|
|
27
|
+
expect(() => validateBaseUrl("anthropic", "http://localhost:8080/v1", { allowLoopback: true }))
|
|
28
|
+
.toThrow(/probe_target_forbidden/); // 但 host 还是不在 allowlist, allowLoopback 只放过 protocol
|
|
29
|
+
});
|
|
30
|
+
test("malformed URL → probe_base_url_invalid", () => {
|
|
31
|
+
expect(() => validateBaseUrl("anthropic", "not a url")).toThrow(/probe_base_url_invalid/);
|
|
32
|
+
});
|
|
33
|
+
test("P1 SUPPORTED_VENDORS only contains anthropic", () => {
|
|
34
|
+
expect(SUPPORTED_VENDORS).toEqual(["anthropic"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── RFC-028 P1.5+ — Anthropic-compatible 3rd-party hosts on `anthropic` vendor.
|
|
38
|
+
// Whitelist semantics preserved; new entries require PR + security review.
|
|
39
|
+
// Host allowlist passes here; IP-level guard still fires at fetch time
|
|
40
|
+
// (covered by safelyFetchProbe + docker scenarios qa-rfc028 F + M.ssrf).
|
|
41
|
+
test("anthropic + api.deepseek.com → ok (Anthropic-compatible /v1/messages)", () => {
|
|
42
|
+
expect(() => validateBaseUrl("anthropic", "https://api.deepseek.com/v1")).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
test("anthropic + api.minimax.chat → ok (MiniMax legacy domain)", () => {
|
|
45
|
+
expect(() => validateBaseUrl("anthropic", "https://api.minimax.chat/v1")).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
test("anthropic + api.minimax.io → ok (MiniMax new domain, Vincent live)", () => {
|
|
48
|
+
expect(() => validateBaseUrl("anthropic", "https://api.minimax.io/v1")).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
test("subdomain of allowed host NOT accepted (regex anchored)", () => {
|
|
51
|
+
expect(() => validateBaseUrl("anthropic", "https://attacker.api.deepseek.com/v1")).toThrow(/probe_target_forbidden/);
|
|
52
|
+
expect(() => validateBaseUrl("anthropic", "https://api.minimax.io.attacker.example/v1")).toThrow(/probe_target_forbidden/);
|
|
53
|
+
});
|
|
54
|
+
test("homograph / look-alike host rejected (regex anchored)", () => {
|
|
55
|
+
expect(() => validateBaseUrl("anthropic", "https://api.deepseek.com.attacker/v1")).toThrow(/probe_target_forbidden/);
|
|
56
|
+
expect(() => validateBaseUrl("anthropic", "https://api-deepseek.com/v1")).toThrow(/probe_target_forbidden/);
|
|
57
|
+
});
|
|
58
|
+
test("VENDOR_HOST_ALLOWLIST.anthropic curated list = exactly 4 entries", () => {
|
|
59
|
+
// Lock the curated set — adding a 5th host MUST go through a PR
|
|
60
|
+
// bump this assertion (forces conscious security review).
|
|
61
|
+
expect(VENDOR_HOST_ALLOWLIST.anthropic.length).toBe(4);
|
|
62
|
+
const sources = VENDOR_HOST_ALLOWLIST.anthropic.map(r => r.source).sort();
|
|
63
|
+
expect(sources).toEqual([
|
|
64
|
+
"^api\\.anthropic\\.com$",
|
|
65
|
+
"^api\\.deepseek\\.com$",
|
|
66
|
+
"^api\\.minimax\\.chat$",
|
|
67
|
+
"^api\\.minimax\\.io$",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("isForbiddenIp (§4.4.2 private/reserved IP block — anti SSRF)", () => {
|
|
73
|
+
test("blocks IPv4 private + metadata + CGNAT", () => {
|
|
74
|
+
for (const ip of [
|
|
75
|
+
"10.0.0.1", "10.255.255.255",
|
|
76
|
+
"172.16.0.1", "172.31.255.255",
|
|
77
|
+
"192.168.1.1",
|
|
78
|
+
"127.0.0.1", "127.255.255.255",
|
|
79
|
+
"169.254.169.254", // cloud metadata
|
|
80
|
+
"169.254.0.0",
|
|
81
|
+
"100.64.0.1", "100.127.255.254", // CGNAT
|
|
82
|
+
"0.0.0.0",
|
|
83
|
+
"224.0.0.1", // multicast
|
|
84
|
+
"240.0.0.1", "255.255.255.255", // experimental/broadcast
|
|
85
|
+
]) {
|
|
86
|
+
expect(isForbiddenIp(ip)).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test("allows public IPv4", () => {
|
|
90
|
+
for (const ip of ["1.1.1.1", "8.8.8.8", "104.16.0.1", "172.32.0.1", "172.15.255.254"]) {
|
|
91
|
+
expect(isForbiddenIp(ip)).toBe(false);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
test("blocks IPv6 loopback + link-local + ULA", () => {
|
|
95
|
+
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd00::1"]) {
|
|
96
|
+
expect(isForbiddenIp(ip)).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test("blocks IPv4-mapped IPv6 (anti绕)", () => {
|
|
100
|
+
expect(isForbiddenIp("::ffff:10.0.0.1")).toBe(true);
|
|
101
|
+
expect(isForbiddenIp("::ffff:169.254.169.254")).toBe(true);
|
|
102
|
+
expect(isForbiddenIp("::FFFF:127.0.0.1")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
test("allows public IPv6", () => {
|
|
105
|
+
expect(isForbiddenIp("2606:4700:4700::1111")).toBe(false);
|
|
106
|
+
expect(isForbiddenIp("2001:4860:4860::8888")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isLoopbackHost", () => {
|
|
111
|
+
test("recognizes localhost/127.0.0.1/::1", () => {
|
|
112
|
+
expect(isLoopbackHost("localhost")).toBe(true);
|
|
113
|
+
expect(isLoopbackHost("127.0.0.1")).toBe(true);
|
|
114
|
+
expect(isLoopbackHost("::1")).toBe(true);
|
|
115
|
+
expect(isLoopbackHost("api.example.com")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("assertSecureTlsEnv (§4.4 boot guard)", () => {
|
|
120
|
+
test("clean env passes", () => {
|
|
121
|
+
expect(() => assertSecureTlsEnv({})).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
test("NODE_TLS_REJECT_UNAUTHORIZED=0 throws probe_tls_insecure_disabled", () => {
|
|
124
|
+
expect(() => assertSecureTlsEnv({ NODE_TLS_REJECT_UNAUTHORIZED: "0" }))
|
|
125
|
+
.toThrow(/probe_tls_insecure_disabled/);
|
|
126
|
+
});
|
|
127
|
+
test("NODE_TLS_REJECT_UNAUTHORIZED=1 passes (default safe)", () => {
|
|
128
|
+
expect(() => assertSecureTlsEnv({ NODE_TLS_REJECT_UNAUTHORIZED: "1" })).not.toThrow();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("ProbeAckPayloadSchema (§4.4.4 v3 R3 strict whitelist)", () => {
|
|
133
|
+
test("minimal valid payload parses", () => {
|
|
134
|
+
const ok = { probe_id: "p_1", status: "ok", latency_ms: 180 };
|
|
135
|
+
expect(ProbeAckPayloadSchema.parse(ok)).toEqual(ok);
|
|
136
|
+
});
|
|
137
|
+
test("with raw_status_code parses", () => {
|
|
138
|
+
const r = ProbeAckPayloadSchema.parse({ probe_id: "p_2", status: "auth_fail", raw_status_code: 401, latency_ms: 50 });
|
|
139
|
+
expect(r.raw_status_code).toBe(401);
|
|
140
|
+
});
|
|
141
|
+
test("REJECTS extra error_message field (zod .strict, v3 R3 attacker daemon catch)", () => {
|
|
142
|
+
expect(() => ProbeAckPayloadSchema.parse({
|
|
143
|
+
probe_id: "p_3", status: "auth_fail", latency_ms: 50,
|
|
144
|
+
error_message: "Invalid API key: sk-ant-abc", // ← attacker smuggling
|
|
145
|
+
})).toThrow();
|
|
146
|
+
});
|
|
147
|
+
test("REJECTS extra response_body / response_headers / url / vendor_text", () => {
|
|
148
|
+
for (const extra of [
|
|
149
|
+
{ response_body: "..." },
|
|
150
|
+
{ response_headers: { foo: "bar" } },
|
|
151
|
+
{ url: "https://leak" },
|
|
152
|
+
{ vendor_text: "leak" },
|
|
153
|
+
{ error: "leak" },
|
|
154
|
+
{ detail: "leak" },
|
|
155
|
+
]) {
|
|
156
|
+
expect(() => ProbeAckPayloadSchema.parse({
|
|
157
|
+
probe_id: "p", status: "ok", latency_ms: 1,
|
|
158
|
+
...extra,
|
|
159
|
+
})).toThrow();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test("REJECTS bad enum status", () => {
|
|
163
|
+
expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "bogus", latency_ms: 1 })).toThrow();
|
|
164
|
+
});
|
|
165
|
+
test("REJECTS latency_ms > 60_000 or negative", () => {
|
|
166
|
+
expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "ok", latency_ms: 60_001 })).toThrow();
|
|
167
|
+
expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "ok", latency_ms: -1 })).toThrow();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("deriveErrorLabel (hub-only, daemon CANNOT submit)", () => {
|
|
172
|
+
test("ok → null", () => {
|
|
173
|
+
expect(deriveErrorLabel({ probe_id: "p", status: "ok", latency_ms: 10 })).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
test("auth_fail with code → label includes HTTP code", () => {
|
|
176
|
+
expect(deriveErrorLabel({ probe_id: "p", status: "auth_fail", raw_status_code: 401, latency_ms: 10 }))
|
|
177
|
+
.toContain("401");
|
|
178
|
+
});
|
|
179
|
+
test("every enum has a label or null", () => {
|
|
180
|
+
for (const status of ["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error"] as const) {
|
|
181
|
+
const r = deriveErrorLabel({ probe_id: "p", status, latency_ms: 1 });
|
|
182
|
+
expect(typeof r === "string" || r === null).toBe(true);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("rejectIfSecretLeaked (§4.4.4 belt-and-suspenders)", () => {
|
|
188
|
+
test("clean ack passes", () => {
|
|
189
|
+
expect(() => rejectIfSecretLeaked(
|
|
190
|
+
JSON.stringify({ probe_id: "p", status: "ok" }),
|
|
191
|
+
["sk-very-long-secret-1234567890"],
|
|
192
|
+
)).not.toThrow();
|
|
193
|
+
});
|
|
194
|
+
test("ack containing raw secret → ack_secret_leak (plain)", () => {
|
|
195
|
+
expect(() => rejectIfSecretLeaked(
|
|
196
|
+
JSON.stringify({ probe_id: "p", status: "fail", x: "sk-very-long-secret-1234567890" }),
|
|
197
|
+
["sk-very-long-secret-1234567890"],
|
|
198
|
+
)).toThrow(/ack_secret_leak/);
|
|
199
|
+
});
|
|
200
|
+
test("ack containing URL-encoded secret → ack_secret_leak (url_encoded)", () => {
|
|
201
|
+
const secret = "sk/with+special=chars/here-long-enough";
|
|
202
|
+
const enc = encodeURIComponent(secret);
|
|
203
|
+
expect(() => rejectIfSecretLeaked(
|
|
204
|
+
JSON.stringify({ probe_id: "p", url: `https://x/?key=${enc}` }),
|
|
205
|
+
[secret],
|
|
206
|
+
)).toThrow(/ack_secret_leak/);
|
|
207
|
+
});
|
|
208
|
+
test("ack containing 12-char substring of long secret → ack_secret_leak (substring_12)", () => {
|
|
209
|
+
const secret = "sk-ant-api03-very-long-key-1234567890-abcdef";
|
|
210
|
+
expect(() => rejectIfSecretLeaked(
|
|
211
|
+
JSON.stringify({ probe_id: "p", x: "leak prefix sk-ant-api03 here" }),
|
|
212
|
+
[secret],
|
|
213
|
+
)).toThrow(/ack_secret_leak/);
|
|
214
|
+
});
|
|
215
|
+
test("short secret (<8) not checked (avoid false positive on tiny tokens)", () => {
|
|
216
|
+
expect(() => rejectIfSecretLeaked(
|
|
217
|
+
JSON.stringify({ probe_id: "p", x: "test" }),
|
|
218
|
+
["test"],
|
|
219
|
+
)).not.toThrow();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// RFC-028 P1 §4.4 — probe path validators (pure, no I/O).
|
|
2
|
+
// Host allowlist + IP block + validateBaseUrl extracted into
|
|
3
|
+
// shared/probe-host-allowlist.ts (mirror of agent-node/src/shared/, G9
|
|
4
|
+
// drift guard enforces byte-identical). This module re-exports them so
|
|
5
|
+
// existing hub call sites keep working, and adds hub-only bits:
|
|
6
|
+
// PROBE_STATUS_ENUM + ProbeAckPayloadSchema + deriveErrorLabel +
|
|
7
|
+
// rejectIfSecretLeaked + assertSecureTlsEnv.
|
|
8
|
+
|
|
9
|
+
import { z } from "zod/v4";
|
|
10
|
+
export {
|
|
11
|
+
VENDOR_HOST_ALLOWLIST,
|
|
12
|
+
SUPPORTED_VENDORS,
|
|
13
|
+
ProbeValidationError,
|
|
14
|
+
validateBaseUrl,
|
|
15
|
+
isLoopbackHost,
|
|
16
|
+
isForbiddenIp,
|
|
17
|
+
} from "./shared/probe-host-allowlist.js";
|
|
18
|
+
import { ProbeValidationError } from "./shared/probe-host-allowlist.js";
|
|
19
|
+
|
|
20
|
+
// ── §4.4.5 boot-time TLS env guard (assert before any probe runs) ──
|
|
21
|
+
|
|
22
|
+
export function assertSecureTlsEnv(env: NodeJS.ProcessEnv = process.env): void {
|
|
23
|
+
if (env.NODE_TLS_REJECT_UNAUTHORIZED === "0") {
|
|
24
|
+
throw new ProbeValidationError("probe_tls_insecure_disabled", {
|
|
25
|
+
reason: "NODE_TLS_REJECT_UNAUTHORIZED=0 forbidden — TLS cert validation must be ON for SSRF defense",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── §4.4.4 ProbeAckPayload — STRICT whitelist (v3 R3) ─────────────
|
|
31
|
+
// Daemon ack returns ONLY these fields. Any extra string is dropped
|
|
32
|
+
// at the zod parse boundary. Hub additionally runs rejectIfSecretLeaked
|
|
33
|
+
// (defense in depth).
|
|
34
|
+
|
|
35
|
+
export const PROBE_STATUS_ENUM = [
|
|
36
|
+
"ok",
|
|
37
|
+
"auth_fail",
|
|
38
|
+
"quota",
|
|
39
|
+
"rate_limit",
|
|
40
|
+
"network_error",
|
|
41
|
+
"timeout",
|
|
42
|
+
"redirect_forbidden",
|
|
43
|
+
"vendor_5xx",
|
|
44
|
+
"other_4xx",
|
|
45
|
+
"tls_error",
|
|
46
|
+
// SSRF guards surface their own statuses so dashboard can show the
|
|
47
|
+
// exact reason; deriveErrorLabel maps to UI text.
|
|
48
|
+
"probe_resolve_unsafe_ip",
|
|
49
|
+
"probe_target_forbidden",
|
|
50
|
+
] as const;
|
|
51
|
+
export type ProbeStatus = typeof PROBE_STATUS_ENUM[number];
|
|
52
|
+
|
|
53
|
+
export const ProbeAckPayloadSchema = z.object({
|
|
54
|
+
probe_id: z.string().min(1).max(200),
|
|
55
|
+
status: z.enum(PROBE_STATUS_ENUM),
|
|
56
|
+
raw_status_code: z.number().int().min(100).max(599).optional(),
|
|
57
|
+
latency_ms: z.number().int().min(0).max(60_000),
|
|
58
|
+
}).strict(); // .strict() rejects ANY extra field — error_message / response_body / etc smuggled by attacker daemon
|
|
59
|
+
export type ProbeAckPayload = z.infer<typeof ProbeAckPayloadSchema>;
|
|
60
|
+
|
|
61
|
+
/** Hub-side: map (status, raw_status_code) → human label.
|
|
62
|
+
* daemon NEVER submits this; hub computes from enum + numeric. */
|
|
63
|
+
export function deriveErrorLabel(ack: ProbeAckPayload): string | null {
|
|
64
|
+
switch (ack.status) {
|
|
65
|
+
case "ok": return null;
|
|
66
|
+
case "auth_fail": return `API key 校验失败 (HTTP ${ack.raw_status_code ?? "?"})`;
|
|
67
|
+
case "quota": return "API 额度用尽 (429)";
|
|
68
|
+
case "rate_limit": return "我方 rate limit (60req/min/provider) 触发";
|
|
69
|
+
case "network_error": return "网络不可达 (connect/DNS fail)";
|
|
70
|
+
case "timeout": return "连通性测试超时 (>30s)";
|
|
71
|
+
case "redirect_forbidden": return "vendor 返回 30x redirect, P1 一律拒";
|
|
72
|
+
case "vendor_5xx": return `vendor 服务端错 (HTTP ${ack.raw_status_code ?? "5xx"})`;
|
|
73
|
+
case "other_4xx": return `vendor 客户端错 (HTTP ${ack.raw_status_code ?? "4xx"})`;
|
|
74
|
+
case "tls_error": return "TLS 证书校验失败";
|
|
75
|
+
case "probe_resolve_unsafe_ip": return "SSRF 拒: base_url 解析到禁用 IP 段 (私网/metadata)";
|
|
76
|
+
case "probe_target_forbidden": return "SSRF 拒: base_url host 不在 vendor allowlist";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── §4.4.4 hub-side belt: reject if daemon ack contains a secret ──
|
|
81
|
+
// Even though daemon ack schema (zod .strict()) blocks string fields,
|
|
82
|
+
// this guard catches a daemon impl bug that smuggles secrets via
|
|
83
|
+
// some other means (e.g. probe_id stuffed with key value).
|
|
84
|
+
// Checks plaintext + URL-encoded + 12-char sliding window substring.
|
|
85
|
+
|
|
86
|
+
export function rejectIfSecretLeaked(ackJson: string, knownSecrets: ReadonlyArray<string>): void {
|
|
87
|
+
for (const s of knownSecrets) {
|
|
88
|
+
if (!s || s.length < 8) continue; // avoid false positive on tiny tokens
|
|
89
|
+
if (ackJson.includes(s)) {
|
|
90
|
+
throw new ProbeValidationError("ack_secret_leak", { reason: "plain", len: s.length });
|
|
91
|
+
}
|
|
92
|
+
const enc = encodeURIComponent(s);
|
|
93
|
+
if (enc !== s && ackJson.includes(enc)) {
|
|
94
|
+
throw new ProbeValidationError("ack_secret_leak", { reason: "url_encoded", len: s.length });
|
|
95
|
+
}
|
|
96
|
+
if (s.length >= 16) {
|
|
97
|
+
for (let i = 0; i <= s.length - 12; i++) {
|
|
98
|
+
if (ackJson.includes(s.slice(i, i + 12))) {
|
|
99
|
+
throw new ProbeValidationError("ack_secret_leak", { reason: "substring_12", offset: i });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
putPendingProbeSecret,
|
|
4
|
+
takePendingProbeSecret,
|
|
5
|
+
peekPendingProbeSecret,
|
|
6
|
+
evictExpiredProbeSecrets,
|
|
7
|
+
stopBackgroundProbeTimersForTest,
|
|
8
|
+
type PendingProbeSecret,
|
|
9
|
+
} from "./probe.js";
|
|
10
|
+
|
|
11
|
+
// RFC-028 P1 fold-in #4 — takePendingProbeSecret 4-case unit. Mirrors
|
|
12
|
+
// RFC-026 §4.4 takePendingEnvBlob unit pattern. This is the C2 cross-
|
|
13
|
+
// daemon guard for probes: a daemon-X that intercepts a probe_id minted
|
|
14
|
+
// for daemon-Y must NOT be able to consume it. Wrong-caller branch must
|
|
15
|
+
// NOT evict (so the right daemon can still consume in TTL).
|
|
16
|
+
|
|
17
|
+
function mkSecret(over: Partial<PendingProbeSecret> = {}): Omit<PendingProbeSecret, "expires_at"> {
|
|
18
|
+
return {
|
|
19
|
+
probe_id: over.probe_id ?? "pr_test_1",
|
|
20
|
+
daemon_node_id: over.daemon_node_id ?? "node_daemon_alpha",
|
|
21
|
+
provider_id: over.provider_id ?? "prov_1",
|
|
22
|
+
vendor: over.vendor ?? "anthropic",
|
|
23
|
+
base_url: over.base_url ?? "https://api.anthropic.com",
|
|
24
|
+
model_name: over.model_name ?? "claude-opus-test",
|
|
25
|
+
api_key: over.api_key ?? "sk-secret-not-leaked",
|
|
26
|
+
network_id: over.network_id ?? "net_1",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("RFC-028 P1 — takePendingProbeSecret 4-case", () => {
|
|
31
|
+
beforeEach(() => stopBackgroundProbeTimersForTest());
|
|
32
|
+
|
|
33
|
+
test("case 1: happy path — right daemon, within TTL → returns + evicts", () => {
|
|
34
|
+
putPendingProbeSecret(mkSecret({ probe_id: "pr_happy" }));
|
|
35
|
+
expect(peekPendingProbeSecret("pr_happy")).not.toBeNull();
|
|
36
|
+
const out = takePendingProbeSecret("pr_happy", "node_daemon_alpha");
|
|
37
|
+
expect(out).not.toBeNull();
|
|
38
|
+
expect(out!.api_key).toBe("sk-secret-not-leaked");
|
|
39
|
+
// Evicted on success → second take returns null
|
|
40
|
+
expect(takePendingProbeSecret("pr_happy", "node_daemon_alpha")).toBeNull();
|
|
41
|
+
expect(peekPendingProbeSecret("pr_happy")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("case 2: wrong daemon → returns null AND does NOT evict (right daemon can still consume)", () => {
|
|
45
|
+
putPendingProbeSecret(mkSecret({ probe_id: "pr_wrongcaller", daemon_node_id: "node_daemon_alpha" }));
|
|
46
|
+
// attacker daemon tries to consume
|
|
47
|
+
const wrong = takePendingProbeSecret("pr_wrongcaller", "node_daemon_attacker");
|
|
48
|
+
expect(wrong).toBeNull();
|
|
49
|
+
// entry MUST still be there — peek confirms (this is the C2 invariant:
|
|
50
|
+
// a wrong-caller probe cannot DoS the legitimate one by triggering eviction)
|
|
51
|
+
const peeked = peekPendingProbeSecret("pr_wrongcaller");
|
|
52
|
+
expect(peeked).not.toBeNull();
|
|
53
|
+
expect(peeked!.daemon_node_id).toBe("node_daemon_alpha");
|
|
54
|
+
// right daemon STILL gets it
|
|
55
|
+
const right = takePendingProbeSecret("pr_wrongcaller", "node_daemon_alpha");
|
|
56
|
+
expect(right).not.toBeNull();
|
|
57
|
+
expect(right!.api_key).toBe("sk-secret-not-leaked");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("case 3: not found → returns null (no side-effect)", () => {
|
|
61
|
+
const out = takePendingProbeSecret("pr_nonexistent", "node_daemon_alpha");
|
|
62
|
+
expect(out).toBeNull();
|
|
63
|
+
// also confirm peek is null
|
|
64
|
+
expect(peekPendingProbeSecret("pr_nonexistent")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("case 4: expired → returns null + evicts (so memory doesn't accumulate)", async () => {
|
|
68
|
+
// Use evictExpiredProbeSecrets's now-arg surrogate: put a fresh entry then
|
|
69
|
+
// ask take with a time-shifted view by mutating the map. Since the public
|
|
70
|
+
// API doesn't expose expires_at directly, we use the sweeper to verify
|
|
71
|
+
// expired entries get cleaned, then assert take returns null.
|
|
72
|
+
putPendingProbeSecret(mkSecret({ probe_id: "pr_expired" }));
|
|
73
|
+
// Sweep with a forced "now" 120s in the future → past TTL_MS (60s)
|
|
74
|
+
const n = evictExpiredProbeSecrets(Date.now() + 120_000);
|
|
75
|
+
expect(n).toBeGreaterThanOrEqual(1);
|
|
76
|
+
// Now any take must return null + the entry is gone
|
|
77
|
+
expect(takePendingProbeSecret("pr_expired", "node_daemon_alpha")).toBeNull();
|
|
78
|
+
expect(peekPendingProbeSecret("pr_expired")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// RFC-028 P1 §2.5 — hub-side probe state (pendingProbeSecrets Map +
|
|
2
|
+
// orphan revoke sweeper + finalize helper). MCP tools register in
|
|
3
|
+
// tools.ts (calls into here).
|
|
4
|
+
//
|
|
5
|
+
// Mirrors RFC-026 §4.4 mint-stream-evict pattern:
|
|
6
|
+
// - hub creates probe row (metadata only) + stashes decrypted secret
|
|
7
|
+
// + child fetcher params in in-memory Map (TTL 60s)
|
|
8
|
+
// - daemon get_probe_request takes (one-shot consume + evict)
|
|
9
|
+
// - daemon ack_probe_request → hub deriveErrorLabel + final row write
|
|
10
|
+
// - sweeper revokes orphan rows + tokens (P2 not needed since probe
|
|
11
|
+
// doesn't mint long-lived tokens — short-window only)
|
|
12
|
+
|
|
13
|
+
import { db, uuidv4 } from "./db.js";
|
|
14
|
+
import { vaultGet, VaultError } from "./vault.js";
|
|
15
|
+
import {
|
|
16
|
+
validateBaseUrl, deriveErrorLabel, rejectIfSecretLeaked,
|
|
17
|
+
ProbeAckPayloadSchema, type ProbeAckPayload,
|
|
18
|
+
ProbeValidationError,
|
|
19
|
+
} from "./probe-validate.js";
|
|
20
|
+
|
|
21
|
+
// ── Probe ephemeral state Map (F1 mint-stream-evict, RFC-028 §2.5) ──
|
|
22
|
+
|
|
23
|
+
export interface PendingProbeSecret {
|
|
24
|
+
probe_id: string;
|
|
25
|
+
daemon_node_id: string; // C2 cross-daemon guard
|
|
26
|
+
provider_id: string;
|
|
27
|
+
vendor: string;
|
|
28
|
+
base_url: string;
|
|
29
|
+
model_name: string;
|
|
30
|
+
api_key: string; // plaintext, ephemeral, never persisted
|
|
31
|
+
network_id: string;
|
|
32
|
+
expires_at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TTL_MS = 60_000;
|
|
36
|
+
const SWEEPER_INTERVAL_MS = 30_000;
|
|
37
|
+
const REAPER_TTL_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
const pendingProbeSecrets = new Map<string, PendingProbeSecret>();
|
|
40
|
+
let _gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
let _sweeperTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
|
|
43
|
+
export function putPendingProbeSecret(b: Omit<PendingProbeSecret, "expires_at">): void {
|
|
44
|
+
pendingProbeSecrets.set(b.probe_id, { ...b, expires_at: Date.now() + TTL_MS });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Caller-bound one-shot consume. Returns null if not found, wrong
|
|
48
|
+
* daemon, or expired; entry deleted on success. */
|
|
49
|
+
export function takePendingProbeSecret(probe_id: string, callerDaemonNodeId: string): PendingProbeSecret | null {
|
|
50
|
+
const b = pendingProbeSecrets.get(probe_id);
|
|
51
|
+
if (!b) return null;
|
|
52
|
+
if (b.daemon_node_id !== callerDaemonNodeId) return null; // wrong-caller probe: DON'T evict
|
|
53
|
+
if (Date.now() > b.expires_at) { pendingProbeSecrets.delete(probe_id); return null; }
|
|
54
|
+
pendingProbeSecrets.delete(probe_id);
|
|
55
|
+
return b;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function peekPendingProbeSecret(probe_id: string): PendingProbeSecret | null {
|
|
59
|
+
return pendingProbeSecrets.get(probe_id) || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function evictExpiredProbeSecrets(now = Date.now()): number {
|
|
63
|
+
let n = 0;
|
|
64
|
+
for (const [k, v] of pendingProbeSecrets) {
|
|
65
|
+
if (now > v.expires_at) { pendingProbeSecrets.delete(k); n++; }
|
|
66
|
+
}
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function startPendingProbeGcTimer(): void {
|
|
71
|
+
if (_gcTimer) return;
|
|
72
|
+
_gcTimer = setInterval(() => {
|
|
73
|
+
const n = evictExpiredProbeSecrets();
|
|
74
|
+
if (n > 0) console.log(`[probe] GC: evicted ${n} expired probe secret(s)`);
|
|
75
|
+
}, 5_000);
|
|
76
|
+
(_gcTimer as any).unref?.();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function stopBackgroundProbeTimersForTest(): void {
|
|
80
|
+
if (_gcTimer) { clearInterval(_gcTimer); _gcTimer = null; }
|
|
81
|
+
if (_sweeperTimer) { clearInterval(_sweeperTimer); _sweeperTimer = null; }
|
|
82
|
+
pendingProbeSecrets.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Sweeper: terminal-transition probe_results rows that never got ack ──
|
|
86
|
+
// Mirrors RFC-026 §4.4.8 — probe rows older than REAPER_TTL_MS that
|
|
87
|
+
// are still status='pending' get marked timeout + error_label set.
|
|
88
|
+
|
|
89
|
+
export function runOrphanProbeSweepOnce(now = Date.now()): { swept: number } {
|
|
90
|
+
const cutoffMs = now - REAPER_TTL_MS;
|
|
91
|
+
const stale = db.all<{ probe_id: string }>(
|
|
92
|
+
`SELECT probe_id FROM probe_results WHERE status = 'pending' AND probed_at < ?1`,
|
|
93
|
+
cutoffMs,
|
|
94
|
+
);
|
|
95
|
+
if (stale.length === 0) return { swept: 0 };
|
|
96
|
+
|
|
97
|
+
let swept = 0;
|
|
98
|
+
try {
|
|
99
|
+
db.exec("BEGIN");
|
|
100
|
+
for (const row of stale) {
|
|
101
|
+
db.run(
|
|
102
|
+
`UPDATE probe_results SET status = ?1, error_label = ?2 WHERE probe_id = ?3 AND status = 'pending'`,
|
|
103
|
+
["timeout", "连通性测试超时 (sweeper: 60s 无 daemon ack)", row.probe_id],
|
|
104
|
+
);
|
|
105
|
+
pendingProbeSecrets.delete(row.probe_id);
|
|
106
|
+
swept++;
|
|
107
|
+
}
|
|
108
|
+
db.exec("COMMIT");
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
try { db.exec("ROLLBACK"); } catch { /* ok */ }
|
|
111
|
+
console.warn(`[probe] sweeper failed: ${e?.message || e}`);
|
|
112
|
+
return { swept: 0 };
|
|
113
|
+
}
|
|
114
|
+
if (swept > 0) console.log(`[commhub] ✓ probe sweeper: swept ${swept} stale probe(s)`);
|
|
115
|
+
return { swept };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function startProbeSweeperTimer(): void {
|
|
119
|
+
if (_sweeperTimer) return;
|
|
120
|
+
try { runOrphanProbeSweepOnce(); }
|
|
121
|
+
catch (e: any) { console.warn(`[probe] boot sweeper failed: ${e?.message || e}`); }
|
|
122
|
+
_sweeperTimer = setInterval(() => {
|
|
123
|
+
try { runOrphanProbeSweepOnce(); }
|
|
124
|
+
catch (e: any) { console.warn(`[probe] periodic sweeper failed: ${e?.message || e}`); }
|
|
125
|
+
}, SWEEPER_INTERVAL_MS);
|
|
126
|
+
(_sweeperTimer as any).unref?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export function newProbeId(): string {
|
|
132
|
+
return `pr_${uuidv4()}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Hub-side: receive daemon ack, validate, redact, derive label, write
|
|
136
|
+
* final row. Returns the persisted row OR throws ProbeValidationError. */
|
|
137
|
+
export function finalizeProbeAck(
|
|
138
|
+
rawAck: unknown,
|
|
139
|
+
caller: { network_id: string; daemon_node_id: string },
|
|
140
|
+
): { ok: boolean; error_label: string | null; status: string } {
|
|
141
|
+
// 1. Schema parse — rejects extra fields via .strict()
|
|
142
|
+
const ack = ProbeAckPayloadSchema.parse(rawAck);
|
|
143
|
+
// 2. Pull the row (existence + caller binding)
|
|
144
|
+
const row = db.get<{ probe_id: string; daemon_node_id: string; provider_id: string; status: string }>(
|
|
145
|
+
`SELECT probe_id, daemon_node_id, provider_id, status FROM probe_results WHERE probe_id = ?1`,
|
|
146
|
+
ack.probe_id,
|
|
147
|
+
);
|
|
148
|
+
if (!row) throw new ProbeValidationError("probe_not_found", { probe_id: ack.probe_id });
|
|
149
|
+
if (row.daemon_node_id !== caller.daemon_node_id) {
|
|
150
|
+
throw new ProbeValidationError("not_your_probe", { probe_id: ack.probe_id });
|
|
151
|
+
}
|
|
152
|
+
if (row.status !== "pending") {
|
|
153
|
+
throw new ProbeValidationError("probe_not_pending", { current_status: row.status });
|
|
154
|
+
}
|
|
155
|
+
// 3. Belt-and-suspenders secret-leak guard on full ack JSON
|
|
156
|
+
// (catches daemon impl bug stuffing secret into probe_id etc.)
|
|
157
|
+
try {
|
|
158
|
+
const providerRow = db.get<{ secret_key_ref: string }>(
|
|
159
|
+
`SELECT secret_key_ref FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
|
|
160
|
+
row.provider_id, caller.network_id,
|
|
161
|
+
);
|
|
162
|
+
if (providerRow) {
|
|
163
|
+
const secretVal = vaultGet(caller.network_id, providerRow.secret_key_ref);
|
|
164
|
+
if (secretVal) {
|
|
165
|
+
rejectIfSecretLeaked(JSON.stringify(ack), [secretVal]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof ProbeValidationError && e.code === "ack_secret_leak") {
|
|
170
|
+
// audit-log this serious event (impl pattern below in tools.ts)
|
|
171
|
+
console.warn(`[probe] ack_secret_leak: daemon=${caller.daemon_node_id} probe=${ack.probe_id} reason=${JSON.stringify(e.detail)}`);
|
|
172
|
+
// mark row as failed for safety
|
|
173
|
+
db.run(
|
|
174
|
+
`UPDATE probe_results SET status = 'tls_error', error_label = ?1 WHERE probe_id = ?2 AND status = 'pending'`,
|
|
175
|
+
["内部安全异常: daemon ack 含 secret 值, 已拒并 audit", ack.probe_id],
|
|
176
|
+
);
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
// VaultError (missing key etc.) — non-fatal, skip leak guard but log
|
|
180
|
+
if (e instanceof VaultError) {
|
|
181
|
+
console.warn(`[probe] leak-guard skipped (vault read failed): ${e.message}`);
|
|
182
|
+
} else {
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. Derive error_label from enum + raw_status_code
|
|
188
|
+
const label = deriveErrorLabel(ack);
|
|
189
|
+
// 5. Final write
|
|
190
|
+
db.run(
|
|
191
|
+
`UPDATE probe_results
|
|
192
|
+
SET status = ?1, latency_ms = ?2, raw_status_code = ?3, error_label = ?4
|
|
193
|
+
WHERE probe_id = ?5 AND status = 'pending'`,
|
|
194
|
+
[ack.status, ack.latency_ms, ack.raw_status_code ?? null, label, ack.probe_id],
|
|
195
|
+
);
|
|
196
|
+
return { ok: true, status: ack.status, error_label: label };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Re-export validate funcs for tools.ts convenience
|
|
200
|
+
export { validateBaseUrl, ProbeValidationError };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
VENDOR_HOST_ALLOWLIST as HUB_ALLOW,
|
|
6
|
+
FORBIDDEN_IPV4_RE as HUB_V4,
|
|
7
|
+
FORBIDDEN_IPV6_RE as HUB_V6,
|
|
8
|
+
} from "./probe-host-allowlist.js";
|
|
9
|
+
|
|
10
|
+
// RFC-028 P1 fold-in #1 — hub-and-daemon probe-host-allowlist drift
|
|
11
|
+
// guard. Mirrors the G9 reserved-env drift test (RFC-026 §4.4.7).
|
|
12
|
+
// Two enforcement modes:
|
|
13
|
+
// 1. byte-identical source files
|
|
14
|
+
// 2. runtime set-equality of the imported constants (vendor regex
|
|
15
|
+
// source strings + private-IP regex source strings)
|
|
16
|
+
// If a future contributor edits one side without the other, this test
|
|
17
|
+
// breaks the merge — the daemon-side compromised-hub defense depends
|
|
18
|
+
// on the two lists being identical.
|
|
19
|
+
|
|
20
|
+
const HUB_PATH = join(import.meta.dir, "probe-host-allowlist.ts");
|
|
21
|
+
const DAEMON_PATH = join(
|
|
22
|
+
import.meta.dir,
|
|
23
|
+
"..", "..", "..", "agent-node", "src", "shared", "probe-host-allowlist.ts",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
describe("RFC-028 P1 — hub/daemon probe-host-allowlist drift guard", () => {
|
|
27
|
+
test("byte-identical source files", () => {
|
|
28
|
+
const hub = readFileSync(HUB_PATH, "utf-8");
|
|
29
|
+
const daemon = readFileSync(DAEMON_PATH, "utf-8");
|
|
30
|
+
expect(daemon).toBe(hub);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("VENDOR_HOST_ALLOWLIST regex sources equal at runtime", async () => {
|
|
34
|
+
const daemonMod = await import(DAEMON_PATH);
|
|
35
|
+
const daemonAllow = daemonMod.VENDOR_HOST_ALLOWLIST as Record<string, ReadonlyArray<RegExp>>;
|
|
36
|
+
// vendor set equality
|
|
37
|
+
expect(new Set(Object.keys(daemonAllow))).toEqual(new Set(Object.keys(HUB_ALLOW)));
|
|
38
|
+
// per-vendor regex source array equality (order matters — both should
|
|
39
|
+
// be authored in identical order to keep diffs minimal)
|
|
40
|
+
for (const vendor of Object.keys(HUB_ALLOW)) {
|
|
41
|
+
const hubSources = HUB_ALLOW[vendor].map(r => r.source);
|
|
42
|
+
const daemonSources = daemonAllow[vendor].map(r => r.source);
|
|
43
|
+
expect(daemonSources).toEqual(hubSources);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("FORBIDDEN_IPV4_RE + FORBIDDEN_IPV6_RE sources equal at runtime", async () => {
|
|
48
|
+
const daemonMod = await import(DAEMON_PATH);
|
|
49
|
+
const daemonV4 = (daemonMod.FORBIDDEN_IPV4_RE as ReadonlyArray<RegExp>).map(r => r.source);
|
|
50
|
+
const daemonV6 = (daemonMod.FORBIDDEN_IPV6_RE as ReadonlyArray<RegExp>).map(r => r.source);
|
|
51
|
+
expect(daemonV4).toEqual(HUB_V4.map(r => r.source));
|
|
52
|
+
expect(daemonV6).toEqual(HUB_V6.map(r => r.source));
|
|
53
|
+
});
|
|
54
|
+
});
|