@vellumai/credential-executor 0.4.55
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/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for HTTP policy evaluation, path-template derivation, and
|
|
3
|
+
* response filtering.
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* - Path template derivation: numeric, UUID, hex placeholder replacement,
|
|
7
|
+
* query/fragment stripping, trailing slash normalisation.
|
|
8
|
+
* - URL-to-template matching.
|
|
9
|
+
* - HTTP policy evaluation: grant matching, forbidden header rejection,
|
|
10
|
+
* proposal generation when no grant matches.
|
|
11
|
+
* - Response filtering: header whitelisting, body clamping, secret scrubbing.
|
|
12
|
+
* - Audit summary generation: token-free output.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
derivePathTemplate,
|
|
20
|
+
deriveAllowedUrlPatterns,
|
|
21
|
+
urlMatchesTemplate,
|
|
22
|
+
} from "../http/path-template.js";
|
|
23
|
+
import {
|
|
24
|
+
evaluateHttpPolicy,
|
|
25
|
+
detectForbiddenHeaders,
|
|
26
|
+
type HttpPolicyRequest,
|
|
27
|
+
} from "../http/policy.js";
|
|
28
|
+
import {
|
|
29
|
+
filterHttpResponse,
|
|
30
|
+
filterResponseHeaders,
|
|
31
|
+
clampBody,
|
|
32
|
+
scrubSecrets,
|
|
33
|
+
type RawHttpResponse,
|
|
34
|
+
} from "../http/response-filter.js";
|
|
35
|
+
import { generateHttpAuditSummary } from "../http/audit.js";
|
|
36
|
+
import { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
37
|
+
import { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
38
|
+
|
|
39
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { tmpdir } from "node:os";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function makeTmpDir(): string {
|
|
48
|
+
const dir = join(tmpdir(), `ces-http-policy-test-${randomUUID()}`);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Path template derivation
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe("derivePathTemplate", () => {
|
|
58
|
+
test("preserves literal path segments", () => {
|
|
59
|
+
const result = derivePathTemplate("https://api.github.com/repos/owner/repo/pulls");
|
|
60
|
+
expect(result).toBe("https://api.github.com/repos/owner/repo/pulls");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("replaces numeric segments with {:num}", () => {
|
|
64
|
+
const result = derivePathTemplate("https://api.example.com/users/42/posts/123");
|
|
65
|
+
expect(result).toBe("https://api.example.com/users/{:num}/posts/{:num}");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("replaces UUID segments with {:uuid}", () => {
|
|
69
|
+
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
|
70
|
+
const result = derivePathTemplate(`https://api.example.com/resources/${uuid}`);
|
|
71
|
+
expect(result).toBe("https://api.example.com/resources/{:uuid}");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("replaces long hex segments with {:hex}", () => {
|
|
75
|
+
const hex = "abcdef0123456789abcdef01";
|
|
76
|
+
const result = derivePathTemplate(`https://api.example.com/commits/${hex}`);
|
|
77
|
+
expect(result).toBe("https://api.example.com/commits/{:hex}");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("does not replace short hex-like segments", () => {
|
|
81
|
+
// "cafe" is only 4 chars — should remain literal
|
|
82
|
+
const result = derivePathTemplate("https://api.example.com/items/cafe");
|
|
83
|
+
expect(result).toBe("https://api.example.com/items/cafe");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("strips query string", () => {
|
|
87
|
+
const result = derivePathTemplate("https://api.example.com/search?q=test&page=1");
|
|
88
|
+
expect(result).toBe("https://api.example.com/search");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("strips fragment", () => {
|
|
92
|
+
const result = derivePathTemplate("https://api.example.com/docs#section-1");
|
|
93
|
+
expect(result).toBe("https://api.example.com/docs");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("strips both query and fragment", () => {
|
|
97
|
+
const result = derivePathTemplate("https://api.example.com/path?q=x#frag");
|
|
98
|
+
expect(result).toBe("https://api.example.com/path");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("normalises trailing slash", () => {
|
|
102
|
+
const result = derivePathTemplate("https://api.example.com/repos/");
|
|
103
|
+
expect(result).toBe("https://api.example.com/repos");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("handles root path", () => {
|
|
107
|
+
const result = derivePathTemplate("https://api.example.com/");
|
|
108
|
+
expect(result).toBe("https://api.example.com/");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("preserves port numbers", () => {
|
|
112
|
+
const result = derivePathTemplate("https://localhost:3000/api/v1/users/42");
|
|
113
|
+
expect(result).toBe("https://localhost:3000/api/v1/users/{:num}");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("handles http scheme", () => {
|
|
117
|
+
const result = derivePathTemplate("http://internal.service/data/123");
|
|
118
|
+
expect(result).toBe("http://internal.service/data/{:num}");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("handles mixed segment types", () => {
|
|
122
|
+
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
|
123
|
+
const hex = "abcdef0123456789abcdef";
|
|
124
|
+
const result = derivePathTemplate(
|
|
125
|
+
`https://api.example.com/orgs/acme/repos/${uuid}/commits/${hex}/files/42`,
|
|
126
|
+
);
|
|
127
|
+
expect(result).toBe(
|
|
128
|
+
"https://api.example.com/orgs/acme/repos/{:uuid}/commits/{:hex}/files/{:num}",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("throws on invalid URL", () => {
|
|
133
|
+
expect(() => derivePathTemplate("not-a-url")).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("never produces wildcard or /* patterns", () => {
|
|
137
|
+
// Verify that no possible input produces /* or host wildcards
|
|
138
|
+
const urls = [
|
|
139
|
+
"https://api.example.com/",
|
|
140
|
+
"https://api.example.com/a/b/c",
|
|
141
|
+
"https://api.example.com/users/42",
|
|
142
|
+
];
|
|
143
|
+
for (const url of urls) {
|
|
144
|
+
const result = derivePathTemplate(url);
|
|
145
|
+
expect(result).not.toContain("/*");
|
|
146
|
+
expect(result).not.toContain("*.");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("deriveAllowedUrlPatterns", () => {
|
|
152
|
+
test("returns array with single pattern", () => {
|
|
153
|
+
const result = deriveAllowedUrlPatterns("https://api.example.com/users/42");
|
|
154
|
+
expect(result).toHaveLength(1);
|
|
155
|
+
expect(result[0]).toBe("https://api.example.com/users/{:num}");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// URL-to-template matching
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe("urlMatchesTemplate", () => {
|
|
164
|
+
test("matches literal paths exactly", () => {
|
|
165
|
+
expect(
|
|
166
|
+
urlMatchesTemplate(
|
|
167
|
+
"https://api.github.com/repos/owner/repo",
|
|
168
|
+
"https://api.github.com/repos/owner/repo",
|
|
169
|
+
),
|
|
170
|
+
).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("matches {:num} placeholder against numeric segments", () => {
|
|
174
|
+
expect(
|
|
175
|
+
urlMatchesTemplate(
|
|
176
|
+
"https://api.example.com/users/42",
|
|
177
|
+
"https://api.example.com/users/{:num}",
|
|
178
|
+
),
|
|
179
|
+
).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("rejects non-numeric segment against {:num}", () => {
|
|
183
|
+
expect(
|
|
184
|
+
urlMatchesTemplate(
|
|
185
|
+
"https://api.example.com/users/alice",
|
|
186
|
+
"https://api.example.com/users/{:num}",
|
|
187
|
+
),
|
|
188
|
+
).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("matches {:uuid} placeholder against UUID segments", () => {
|
|
192
|
+
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
|
193
|
+
expect(
|
|
194
|
+
urlMatchesTemplate(
|
|
195
|
+
`https://api.example.com/resources/${uuid}`,
|
|
196
|
+
"https://api.example.com/resources/{:uuid}",
|
|
197
|
+
),
|
|
198
|
+
).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rejects non-UUID segment against {:uuid}", () => {
|
|
202
|
+
expect(
|
|
203
|
+
urlMatchesTemplate(
|
|
204
|
+
"https://api.example.com/resources/not-a-uuid",
|
|
205
|
+
"https://api.example.com/resources/{:uuid}",
|
|
206
|
+
),
|
|
207
|
+
).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("matches {:hex} placeholder against long hex segments", () => {
|
|
211
|
+
expect(
|
|
212
|
+
urlMatchesTemplate(
|
|
213
|
+
"https://api.example.com/commits/abcdef0123456789abcdef01",
|
|
214
|
+
"https://api.example.com/commits/{:hex}",
|
|
215
|
+
),
|
|
216
|
+
).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("rejects short hex against {:hex}", () => {
|
|
220
|
+
expect(
|
|
221
|
+
urlMatchesTemplate(
|
|
222
|
+
"https://api.example.com/commits/abcdef",
|
|
223
|
+
"https://api.example.com/commits/{:hex}",
|
|
224
|
+
),
|
|
225
|
+
).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("rejects different path lengths", () => {
|
|
229
|
+
expect(
|
|
230
|
+
urlMatchesTemplate(
|
|
231
|
+
"https://api.example.com/users/42/extra",
|
|
232
|
+
"https://api.example.com/users/{:num}",
|
|
233
|
+
),
|
|
234
|
+
).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects different hosts", () => {
|
|
238
|
+
expect(
|
|
239
|
+
urlMatchesTemplate(
|
|
240
|
+
"https://evil.example.com/users/42",
|
|
241
|
+
"https://api.example.com/users/{:num}",
|
|
242
|
+
),
|
|
243
|
+
).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("rejects different schemes", () => {
|
|
247
|
+
expect(
|
|
248
|
+
urlMatchesTemplate(
|
|
249
|
+
"http://api.example.com/users/42",
|
|
250
|
+
"https://api.example.com/users/{:num}",
|
|
251
|
+
),
|
|
252
|
+
).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("host comparison is case-insensitive", () => {
|
|
256
|
+
expect(
|
|
257
|
+
urlMatchesTemplate(
|
|
258
|
+
"https://API.Example.COM/users/42",
|
|
259
|
+
"https://api.example.com/users/{:num}",
|
|
260
|
+
),
|
|
261
|
+
).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("path comparison is case-sensitive for literals", () => {
|
|
265
|
+
expect(
|
|
266
|
+
urlMatchesTemplate(
|
|
267
|
+
"https://api.example.com/Users/42",
|
|
268
|
+
"https://api.example.com/users/{:num}",
|
|
269
|
+
),
|
|
270
|
+
).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("ignores query string in the URL being matched", () => {
|
|
274
|
+
expect(
|
|
275
|
+
urlMatchesTemplate(
|
|
276
|
+
"https://api.example.com/users/42?include=profile",
|
|
277
|
+
"https://api.example.com/users/{:num}",
|
|
278
|
+
),
|
|
279
|
+
).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("returns false for invalid URL", () => {
|
|
283
|
+
expect(urlMatchesTemplate("not-a-url", "https://example.com/")).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("returns false for invalid template", () => {
|
|
287
|
+
expect(urlMatchesTemplate("https://example.com/", "not-a-url")).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Forbidden header detection
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
describe("detectForbiddenHeaders", () => {
|
|
296
|
+
test("detects Authorization header", () => {
|
|
297
|
+
const result = detectForbiddenHeaders({ Authorization: "Bearer xyz" });
|
|
298
|
+
expect(result).toContain("Authorization");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("detects Cookie header (case-insensitive)", () => {
|
|
302
|
+
const result = detectForbiddenHeaders({ cookie: "session=abc" });
|
|
303
|
+
expect(result).toContain("cookie");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("detects Proxy-Authorization header", () => {
|
|
307
|
+
const result = detectForbiddenHeaders({
|
|
308
|
+
"Proxy-Authorization": "Basic abc",
|
|
309
|
+
});
|
|
310
|
+
expect(result).toContain("Proxy-Authorization");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("detects X-Api-Key header", () => {
|
|
314
|
+
const result = detectForbiddenHeaders({ "X-Api-Key": "secret" });
|
|
315
|
+
expect(result).toContain("X-Api-Key");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("detects X-Auth-Token header", () => {
|
|
319
|
+
const result = detectForbiddenHeaders({ "X-Auth-Token": "token" });
|
|
320
|
+
expect(result).toContain("X-Auth-Token");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("returns empty array for safe headers", () => {
|
|
324
|
+
const result = detectForbiddenHeaders({
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
Accept: "application/json",
|
|
327
|
+
});
|
|
328
|
+
expect(result).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("returns empty array for undefined headers", () => {
|
|
332
|
+
expect(detectForbiddenHeaders(undefined)).toEqual([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("detects multiple forbidden headers", () => {
|
|
336
|
+
const result = detectForbiddenHeaders({
|
|
337
|
+
Authorization: "Bearer xyz",
|
|
338
|
+
Cookie: "session=abc",
|
|
339
|
+
"Content-Type": "application/json",
|
|
340
|
+
});
|
|
341
|
+
expect(result).toHaveLength(2);
|
|
342
|
+
expect(result).toContain("Authorization");
|
|
343
|
+
expect(result).toContain("Cookie");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// HTTP policy evaluation
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
describe("evaluateHttpPolicy", () => {
|
|
352
|
+
let tmpDir: string;
|
|
353
|
+
let persistentStore: PersistentGrantStore;
|
|
354
|
+
let temporaryStore: TemporaryGrantStore;
|
|
355
|
+
|
|
356
|
+
beforeEach(() => {
|
|
357
|
+
tmpDir = makeTmpDir();
|
|
358
|
+
persistentStore = new PersistentGrantStore(tmpDir);
|
|
359
|
+
persistentStore.init();
|
|
360
|
+
temporaryStore = new TemporaryGrantStore();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("blocks requests with forbidden auth headers", () => {
|
|
364
|
+
const request: HttpPolicyRequest = {
|
|
365
|
+
credentialHandle: "local_static:github/api_key",
|
|
366
|
+
method: "GET",
|
|
367
|
+
url: "https://api.github.com/repos/owner/repo",
|
|
368
|
+
headers: { Authorization: "Bearer smuggled-token" },
|
|
369
|
+
purpose: "List repos",
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
373
|
+
expect(result.allowed).toBe(false);
|
|
374
|
+
if (!result.allowed) {
|
|
375
|
+
expect(result.reason).toBe("forbidden_headers");
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("allows request with matching persistent grant", () => {
|
|
380
|
+
persistentStore.add({
|
|
381
|
+
id: "grant-1",
|
|
382
|
+
tool: "http",
|
|
383
|
+
pattern: "GET https://api.github.com/repos/owner/repo",
|
|
384
|
+
scope: "local_static:github/api_key",
|
|
385
|
+
createdAt: Date.now(),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const request: HttpPolicyRequest = {
|
|
389
|
+
credentialHandle: "local_static:github/api_key",
|
|
390
|
+
method: "GET",
|
|
391
|
+
url: "https://api.github.com/repos/owner/repo",
|
|
392
|
+
purpose: "List repos",
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
396
|
+
expect(result.allowed).toBe(true);
|
|
397
|
+
if (result.allowed) {
|
|
398
|
+
expect(result.grantId).toBe("grant-1");
|
|
399
|
+
expect(result.grantSource).toBe("persistent");
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("allows request with explicit grantId", () => {
|
|
404
|
+
persistentStore.add({
|
|
405
|
+
id: "explicit-grant",
|
|
406
|
+
tool: "http",
|
|
407
|
+
pattern: "POST https://api.example.com/data",
|
|
408
|
+
scope: "local_static:svc/key",
|
|
409
|
+
createdAt: Date.now(),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const request: HttpPolicyRequest = {
|
|
413
|
+
credentialHandle: "local_static:svc/key",
|
|
414
|
+
method: "POST",
|
|
415
|
+
url: "https://api.example.com/data",
|
|
416
|
+
purpose: "Post data",
|
|
417
|
+
grantId: "explicit-grant",
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
421
|
+
expect(result.allowed).toBe(true);
|
|
422
|
+
if (result.allowed) {
|
|
423
|
+
expect(result.grantId).toBe("explicit-grant");
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("matches persistent grant with templated URL patterns", () => {
|
|
428
|
+
persistentStore.add({
|
|
429
|
+
id: "templated-grant",
|
|
430
|
+
tool: "http",
|
|
431
|
+
pattern: "GET https://api.example.com/users/{:num}/posts",
|
|
432
|
+
scope: "local_static:svc/key",
|
|
433
|
+
createdAt: Date.now(),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const request: HttpPolicyRequest = {
|
|
437
|
+
credentialHandle: "local_static:svc/key",
|
|
438
|
+
method: "GET",
|
|
439
|
+
url: "https://api.example.com/users/42/posts",
|
|
440
|
+
purpose: "List posts",
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
444
|
+
expect(result.allowed).toBe(true);
|
|
445
|
+
if (result.allowed) {
|
|
446
|
+
expect(result.grantId).toBe("templated-grant");
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("returns approval_required when no grant matches", () => {
|
|
451
|
+
const request: HttpPolicyRequest = {
|
|
452
|
+
credentialHandle: "local_static:github/api_key",
|
|
453
|
+
method: "GET",
|
|
454
|
+
url: "https://api.github.com/repos/owner/repo/pulls/42",
|
|
455
|
+
purpose: "List pull requests",
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
459
|
+
expect(result.allowed).toBe(false);
|
|
460
|
+
if (!result.allowed && result.reason === "approval_required") {
|
|
461
|
+
expect(result.proposal.type).toBe("http");
|
|
462
|
+
expect(result.proposal.credentialHandle).toBe(
|
|
463
|
+
"local_static:github/api_key",
|
|
464
|
+
);
|
|
465
|
+
expect(result.proposal.method).toBe("GET");
|
|
466
|
+
// Proposal should have allowedUrlPatterns with templated path
|
|
467
|
+
expect(result.proposal.allowedUrlPatterns).toBeDefined();
|
|
468
|
+
expect(result.proposal.allowedUrlPatterns![0]).toBe(
|
|
469
|
+
"https://api.github.com/repos/owner/repo/pulls/{:num}",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("proposal derivation never produces wildcards", () => {
|
|
475
|
+
const request: HttpPolicyRequest = {
|
|
476
|
+
credentialHandle: "local_static:svc/key",
|
|
477
|
+
method: "GET",
|
|
478
|
+
url: "https://api.example.com/resources/42",
|
|
479
|
+
purpose: "Get resource",
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
483
|
+
expect(result.allowed).toBe(false);
|
|
484
|
+
if (!result.allowed && result.reason === "approval_required") {
|
|
485
|
+
for (const pattern of result.proposal.allowedUrlPatterns ?? []) {
|
|
486
|
+
expect(pattern).not.toContain("/*");
|
|
487
|
+
expect(pattern).not.toContain("*.");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("does not match grant with different credential handle", () => {
|
|
493
|
+
persistentStore.add({
|
|
494
|
+
id: "wrong-cred-grant",
|
|
495
|
+
tool: "http",
|
|
496
|
+
pattern: "GET https://api.example.com/data",
|
|
497
|
+
scope: "local_static:other/key",
|
|
498
|
+
createdAt: Date.now(),
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const request: HttpPolicyRequest = {
|
|
502
|
+
credentialHandle: "local_static:svc/key",
|
|
503
|
+
method: "GET",
|
|
504
|
+
url: "https://api.example.com/data",
|
|
505
|
+
purpose: "Get data",
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
509
|
+
expect(result.allowed).toBe(false);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("does not match grant with different HTTP method", () => {
|
|
513
|
+
persistentStore.add({
|
|
514
|
+
id: "get-only-grant",
|
|
515
|
+
tool: "http",
|
|
516
|
+
pattern: "GET https://api.example.com/data",
|
|
517
|
+
scope: "local_static:svc/key",
|
|
518
|
+
createdAt: Date.now(),
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const request: HttpPolicyRequest = {
|
|
522
|
+
credentialHandle: "local_static:svc/key",
|
|
523
|
+
method: "POST",
|
|
524
|
+
url: "https://api.example.com/data",
|
|
525
|
+
purpose: "Post data",
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
529
|
+
expect(result.allowed).toBe(false);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("checks forbidden headers before grants", () => {
|
|
533
|
+
// Even with a matching grant, forbidden headers should block
|
|
534
|
+
persistentStore.add({
|
|
535
|
+
id: "valid-grant",
|
|
536
|
+
tool: "http",
|
|
537
|
+
pattern: "GET https://api.example.com/data",
|
|
538
|
+
scope: "local_static:svc/key",
|
|
539
|
+
createdAt: Date.now(),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const request: HttpPolicyRequest = {
|
|
543
|
+
credentialHandle: "local_static:svc/key",
|
|
544
|
+
method: "GET",
|
|
545
|
+
url: "https://api.example.com/data",
|
|
546
|
+
headers: { Authorization: "Bearer smuggled" },
|
|
547
|
+
purpose: "Get data",
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const result = evaluateHttpPolicy(request, persistentStore, temporaryStore);
|
|
551
|
+
expect(result.allowed).toBe(false);
|
|
552
|
+
if (!result.allowed) {
|
|
553
|
+
expect(result.reason).toBe("forbidden_headers");
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Cleanup
|
|
558
|
+
afterEach(() => {
|
|
559
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Response header filtering
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
describe("filterResponseHeaders", () => {
|
|
568
|
+
test("passes through whitelisted headers", () => {
|
|
569
|
+
const result = filterResponseHeaders({
|
|
570
|
+
"Content-Type": "application/json",
|
|
571
|
+
"X-Request-Id": "abc-123",
|
|
572
|
+
ETag: '"abc"',
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(result["content-type"]).toBe("application/json");
|
|
576
|
+
expect(result["x-request-id"]).toBe("abc-123");
|
|
577
|
+
expect(result["etag"]).toBe('"abc"');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("strips set-cookie header", () => {
|
|
581
|
+
const result = filterResponseHeaders({
|
|
582
|
+
"Content-Type": "text/html",
|
|
583
|
+
"Set-Cookie": "session=secret; HttpOnly",
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(result["content-type"]).toBe("text/html");
|
|
587
|
+
expect(result["set-cookie"]).toBeUndefined();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("strips www-authenticate header", () => {
|
|
591
|
+
const result = filterResponseHeaders({
|
|
592
|
+
"WWW-Authenticate": "Bearer realm=api",
|
|
593
|
+
"Content-Type": "application/json",
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
expect(result["www-authenticate"]).toBeUndefined();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("strips arbitrary non-whitelisted headers", () => {
|
|
600
|
+
const result = filterResponseHeaders({
|
|
601
|
+
"X-Custom-Secret": "secret-value",
|
|
602
|
+
"Content-Type": "text/plain",
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
expect(result["x-custom-secret"]).toBeUndefined();
|
|
606
|
+
expect(result["content-type"]).toBe("text/plain");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("lowercases header keys in output", () => {
|
|
610
|
+
const result = filterResponseHeaders({
|
|
611
|
+
"Content-Type": "text/html",
|
|
612
|
+
"Cache-Control": "no-cache",
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
expect(Object.keys(result).every((k) => k === k.toLowerCase())).toBe(true);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// Body clamping
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
describe("clampBody", () => {
|
|
624
|
+
test("passes through small bodies unchanged", () => {
|
|
625
|
+
const body = "Hello, world!";
|
|
626
|
+
const result = clampBody(body);
|
|
627
|
+
|
|
628
|
+
expect(result.clampedBody).toBe(body);
|
|
629
|
+
expect(result.truncated).toBe(false);
|
|
630
|
+
expect(result.originalBytes).toBe(Buffer.byteLength(body));
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("truncates bodies exceeding max size", () => {
|
|
634
|
+
// Create a body larger than 256KB
|
|
635
|
+
const body = "x".repeat(300 * 1024);
|
|
636
|
+
const result = clampBody(body);
|
|
637
|
+
|
|
638
|
+
expect(result.truncated).toBe(true);
|
|
639
|
+
expect(result.originalBytes).toBe(Buffer.byteLength(body));
|
|
640
|
+
expect(result.clampedBody).toContain("[CES: Response truncated");
|
|
641
|
+
// The clamped body should be smaller than the original
|
|
642
|
+
expect(Buffer.byteLength(result.clampedBody)).toBeLessThan(
|
|
643
|
+
Buffer.byteLength(body),
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("reports original byte size", () => {
|
|
648
|
+
const body = "a".repeat(100);
|
|
649
|
+
const result = clampBody(body);
|
|
650
|
+
|
|
651
|
+
expect(result.originalBytes).toBe(100);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// Secret scrubbing
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
describe("scrubSecrets", () => {
|
|
660
|
+
test("replaces exact secret occurrences", () => {
|
|
661
|
+
const secret = "sk-abc123456789xyz";
|
|
662
|
+
const body = `Response: {"api_key": "${secret}", "status": "ok"}`;
|
|
663
|
+
const result = scrubSecrets(body, [secret]);
|
|
664
|
+
|
|
665
|
+
expect(result).not.toContain(secret);
|
|
666
|
+
expect(result).toContain("[CES:REDACTED]");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("replaces multiple occurrences of the same secret", () => {
|
|
670
|
+
const secret = "ghp_1234567890abcdef";
|
|
671
|
+
const body = `token: ${secret}, again: ${secret}`;
|
|
672
|
+
const result = scrubSecrets(body, [secret]);
|
|
673
|
+
|
|
674
|
+
expect(result).not.toContain(secret);
|
|
675
|
+
expect(result.match(/\[CES:REDACTED\]/g)?.length).toBe(2);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("scrubs multiple different secrets", () => {
|
|
679
|
+
const secret1 = "sk-prod-abcdefgh";
|
|
680
|
+
const secret2 = "ghp_testtoken123";
|
|
681
|
+
const body = `key1=${secret1}&key2=${secret2}`;
|
|
682
|
+
const result = scrubSecrets(body, [secret1, secret2]);
|
|
683
|
+
|
|
684
|
+
expect(result).not.toContain(secret1);
|
|
685
|
+
expect(result).not.toContain(secret2);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("skips short secrets to avoid false positives", () => {
|
|
689
|
+
const shortSecret = "abc";
|
|
690
|
+
const body = "abc is a common substring in abcdef";
|
|
691
|
+
const result = scrubSecrets(body, [shortSecret]);
|
|
692
|
+
|
|
693
|
+
// Short secret should not be scrubbed
|
|
694
|
+
expect(result).toBe(body);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("handles empty secrets array", () => {
|
|
698
|
+
const body = "no secrets here";
|
|
699
|
+
const result = scrubSecrets(body, []);
|
|
700
|
+
|
|
701
|
+
expect(result).toBe(body);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("handles body with no matching secrets", () => {
|
|
705
|
+
const body = "clean response body";
|
|
706
|
+
const result = scrubSecrets(body, ["nonexistent-secret-value"]);
|
|
707
|
+
|
|
708
|
+
expect(result).toBe(body);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("handles secrets with regex metacharacters", () => {
|
|
712
|
+
const secret = "secret+value.with$special(chars)";
|
|
713
|
+
const body = `Found: ${secret}`;
|
|
714
|
+
const result = scrubSecrets(body, [secret]);
|
|
715
|
+
|
|
716
|
+
expect(result).not.toContain(secret);
|
|
717
|
+
expect(result).toContain("[CES:REDACTED]");
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Full response filter
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
describe("filterHttpResponse", () => {
|
|
726
|
+
test("combines header filtering, body clamping, and secret scrubbing", () => {
|
|
727
|
+
const secret = "sk-live-1234567890abcdef";
|
|
728
|
+
const raw: RawHttpResponse = {
|
|
729
|
+
statusCode: 200,
|
|
730
|
+
headers: {
|
|
731
|
+
"Content-Type": "application/json",
|
|
732
|
+
"Set-Cookie": "session=abc; HttpOnly",
|
|
733
|
+
"X-Request-Id": "req-123",
|
|
734
|
+
},
|
|
735
|
+
body: `{"data": "value", "echo": "${secret}"}`,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const result = filterHttpResponse(raw, [secret]);
|
|
739
|
+
|
|
740
|
+
expect(result.statusCode).toBe(200);
|
|
741
|
+
expect(result.headers["content-type"]).toBe("application/json");
|
|
742
|
+
expect(result.headers["x-request-id"]).toBe("req-123");
|
|
743
|
+
expect(result.headers["set-cookie"]).toBeUndefined();
|
|
744
|
+
expect(result.body).not.toContain(secret);
|
|
745
|
+
expect(result.body).toContain("[CES:REDACTED]");
|
|
746
|
+
expect(result.truncated).toBe(false);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("works with no secrets provided", () => {
|
|
750
|
+
const raw: RawHttpResponse = {
|
|
751
|
+
statusCode: 404,
|
|
752
|
+
headers: { "Content-Type": "text/plain" },
|
|
753
|
+
body: "Not found",
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const result = filterHttpResponse(raw);
|
|
757
|
+
|
|
758
|
+
expect(result.statusCode).toBe(404);
|
|
759
|
+
expect(result.body).toBe("Not found");
|
|
760
|
+
expect(result.truncated).toBe(false);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
// Audit summary generation
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
|
|
768
|
+
describe("generateHttpAuditSummary", () => {
|
|
769
|
+
test("produces token-free summary with templated URL", () => {
|
|
770
|
+
const summary = generateHttpAuditSummary({
|
|
771
|
+
credentialHandle: "local_static:github/api_key",
|
|
772
|
+
grantId: "grant-1",
|
|
773
|
+
sessionId: "sess-1",
|
|
774
|
+
method: "GET",
|
|
775
|
+
url: "https://api.github.com/repos/owner/repo/pulls/42",
|
|
776
|
+
success: true,
|
|
777
|
+
statusCode: 200,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
expect(summary.auditId).toBeDefined();
|
|
781
|
+
expect(summary.grantId).toBe("grant-1");
|
|
782
|
+
expect(summary.credentialHandle).toBe("local_static:github/api_key");
|
|
783
|
+
expect(summary.toolName).toBe("http");
|
|
784
|
+
expect(summary.sessionId).toBe("sess-1");
|
|
785
|
+
expect(summary.success).toBe(true);
|
|
786
|
+
expect(summary.timestamp).toBeDefined();
|
|
787
|
+
|
|
788
|
+
// Target should use templated path, not raw URL
|
|
789
|
+
expect(summary.target).toContain("GET");
|
|
790
|
+
expect(summary.target).toContain("{:num}");
|
|
791
|
+
expect(summary.target).toContain("-> 200");
|
|
792
|
+
// Must not contain the raw numeric ID
|
|
793
|
+
expect(summary.target).not.toContain("/42");
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("includes error message on failure", () => {
|
|
797
|
+
const summary = generateHttpAuditSummary({
|
|
798
|
+
credentialHandle: "local_static:svc/key",
|
|
799
|
+
grantId: "grant-2",
|
|
800
|
+
sessionId: "sess-2",
|
|
801
|
+
method: "POST",
|
|
802
|
+
url: "https://api.example.com/data",
|
|
803
|
+
success: false,
|
|
804
|
+
errorMessage: "Connection refused",
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(summary.success).toBe(false);
|
|
808
|
+
expect(summary.errorMessage).toBe("Connection refused");
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("handles invalid URL gracefully in target", () => {
|
|
812
|
+
const summary = generateHttpAuditSummary({
|
|
813
|
+
credentialHandle: "local_static:svc/key",
|
|
814
|
+
grantId: "grant-3",
|
|
815
|
+
sessionId: "sess-3",
|
|
816
|
+
method: "GET",
|
|
817
|
+
url: "not-a-valid-url",
|
|
818
|
+
success: false,
|
|
819
|
+
errorMessage: "Invalid URL",
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
expect(summary.target).toContain("[invalid-url]");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("omits errorMessage when not provided", () => {
|
|
826
|
+
const summary = generateHttpAuditSummary({
|
|
827
|
+
credentialHandle: "local_static:svc/key",
|
|
828
|
+
grantId: "grant-4",
|
|
829
|
+
sessionId: "sess-4",
|
|
830
|
+
method: "GET",
|
|
831
|
+
url: "https://api.example.com/health",
|
|
832
|
+
success: true,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
expect(summary.errorMessage).toBeUndefined();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("audit summary never contains secret values", () => {
|
|
839
|
+
// Even if someone accidentally passes secret-looking data,
|
|
840
|
+
// the summary only contains metadata fields
|
|
841
|
+
const summary = generateHttpAuditSummary({
|
|
842
|
+
credentialHandle: "local_static:github/api_key",
|
|
843
|
+
grantId: "grant-5",
|
|
844
|
+
sessionId: "sess-5",
|
|
845
|
+
method: "GET",
|
|
846
|
+
url: "https://api.github.com/user",
|
|
847
|
+
success: true,
|
|
848
|
+
statusCode: 200,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const serialized = JSON.stringify(summary);
|
|
852
|
+
// The summary should not contain any field that could hold a raw token
|
|
853
|
+
expect(serialized).not.toContain("Bearer");
|
|
854
|
+
expect(serialized).not.toContain("ghp_");
|
|
855
|
+
expect(serialized).not.toContain("sk-");
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// Integration: end-to-end policy → filter → audit
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
describe("end-to-end: policy evaluation → response filter → audit", () => {
|
|
864
|
+
let tmpDir: string;
|
|
865
|
+
|
|
866
|
+
beforeEach(() => {
|
|
867
|
+
tmpDir = makeTmpDir();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
afterEach(() => {
|
|
871
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("off-grant request is blocked, returns proposal, never reaches network", () => {
|
|
875
|
+
const persistentStore = new PersistentGrantStore(tmpDir);
|
|
876
|
+
persistentStore.init();
|
|
877
|
+
const temporaryStore = new TemporaryGrantStore();
|
|
878
|
+
|
|
879
|
+
const request: HttpPolicyRequest = {
|
|
880
|
+
credentialHandle: "local_static:stripe/api_key",
|
|
881
|
+
method: "POST",
|
|
882
|
+
url: "https://api.stripe.com/v1/charges",
|
|
883
|
+
purpose: "Create a charge",
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const policyResult = evaluateHttpPolicy(
|
|
887
|
+
request,
|
|
888
|
+
persistentStore,
|
|
889
|
+
temporaryStore,
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
// Must be blocked
|
|
893
|
+
expect(policyResult.allowed).toBe(false);
|
|
894
|
+
if (!policyResult.allowed && policyResult.reason === "approval_required") {
|
|
895
|
+
expect(policyResult.proposal.type).toBe("http");
|
|
896
|
+
expect(policyResult.proposal.credentialHandle).toBe(
|
|
897
|
+
"local_static:stripe/api_key",
|
|
898
|
+
);
|
|
899
|
+
// Must have specific URL pattern, not wildcard
|
|
900
|
+
expect(policyResult.proposal.allowedUrlPatterns).toBeDefined();
|
|
901
|
+
expect(policyResult.proposal.allowedUrlPatterns!.length).toBeGreaterThan(0);
|
|
902
|
+
for (const pattern of policyResult.proposal.allowedUrlPatterns!) {
|
|
903
|
+
expect(pattern).not.toBe("/*");
|
|
904
|
+
expect(pattern).not.toContain("*");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test("granted request produces sanitised response and clean audit summary", () => {
|
|
910
|
+
const persistentStore = new PersistentGrantStore(tmpDir);
|
|
911
|
+
persistentStore.init();
|
|
912
|
+
persistentStore.add({
|
|
913
|
+
id: "stripe-charge-grant",
|
|
914
|
+
tool: "http",
|
|
915
|
+
pattern: "GET https://api.stripe.com/v1/charges/{:num}",
|
|
916
|
+
scope: "local_static:stripe/api_key",
|
|
917
|
+
createdAt: Date.now(),
|
|
918
|
+
});
|
|
919
|
+
const temporaryStore = new TemporaryGrantStore();
|
|
920
|
+
|
|
921
|
+
const request: HttpPolicyRequest = {
|
|
922
|
+
credentialHandle: "local_static:stripe/api_key",
|
|
923
|
+
method: "GET",
|
|
924
|
+
url: "https://api.stripe.com/v1/charges/123",
|
|
925
|
+
purpose: "Get charge details",
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// Policy allows
|
|
929
|
+
const policyResult = evaluateHttpPolicy(
|
|
930
|
+
request,
|
|
931
|
+
persistentStore,
|
|
932
|
+
temporaryStore,
|
|
933
|
+
);
|
|
934
|
+
expect(policyResult.allowed).toBe(true);
|
|
935
|
+
|
|
936
|
+
// Simulate HTTP response filtering
|
|
937
|
+
const secret = "sk_live_abcdefghijklmnop";
|
|
938
|
+
const raw: RawHttpResponse = {
|
|
939
|
+
statusCode: 200,
|
|
940
|
+
headers: {
|
|
941
|
+
"Content-Type": "application/json",
|
|
942
|
+
"Set-Cookie": "stripe_session=xyz",
|
|
943
|
+
},
|
|
944
|
+
body: `{"id": "ch_123", "key_echo": "${secret}"}`,
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const filtered = filterHttpResponse(raw, [secret]);
|
|
948
|
+
expect(filtered.headers["set-cookie"]).toBeUndefined();
|
|
949
|
+
expect(filtered.body).not.toContain(secret);
|
|
950
|
+
|
|
951
|
+
// Generate audit summary
|
|
952
|
+
if (policyResult.allowed) {
|
|
953
|
+
const audit = generateHttpAuditSummary({
|
|
954
|
+
credentialHandle: request.credentialHandle,
|
|
955
|
+
grantId: policyResult.grantId,
|
|
956
|
+
sessionId: "test-session",
|
|
957
|
+
method: request.method,
|
|
958
|
+
url: request.url,
|
|
959
|
+
success: true,
|
|
960
|
+
statusCode: 200,
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
expect(audit.success).toBe(true);
|
|
964
|
+
expect(audit.target).toContain("{:num}");
|
|
965
|
+
expect(audit.target).not.toContain("123");
|
|
966
|
+
const auditStr = JSON.stringify(audit);
|
|
967
|
+
expect(auditStr).not.toContain(secret);
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
});
|