@integsec/agentic-pentest-proxy 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -0
  2. package/cloudflare/wrangler.jsonc +1 -1
  3. package/dist/bin/integsec-agentic-pentest-bridge.js +2 -2
  4. package/dist/bin/integsec-agentic-pentest-gateway.js +30 -2
  5. package/dist/bin/integsec-agentic-pentest-gateway.js.map +1 -1
  6. package/dist/bin/integsec-agentic-pentest-tui.js +5 -0
  7. package/dist/bin/integsec-agentic-pentest-tui.js.map +1 -1
  8. package/dist/src/audit/local.js +2 -2
  9. package/dist/src/audit/local.js.map +1 -1
  10. package/dist/src/config.js +1 -1
  11. package/dist/src/gateway/admin.html +3 -3
  12. package/dist/src/gateway/docs-home.html +20 -0
  13. package/dist/src/gateway/docs.d.ts +3 -0
  14. package/dist/src/gateway/docs.d.ts.map +1 -0
  15. package/dist/src/gateway/docs.html +12 -0
  16. package/dist/src/gateway/docs.js +94 -0
  17. package/dist/src/gateway/docs.js.map +1 -0
  18. package/dist/src/gateway/local-paths.d.ts +7 -0
  19. package/dist/src/gateway/local-paths.d.ts.map +1 -0
  20. package/dist/src/gateway/local-paths.js +30 -0
  21. package/dist/src/gateway/local-paths.js.map +1 -0
  22. package/dist/src/gateway/runtime.js +2 -2
  23. package/dist/src/gateway/server.d.ts.map +1 -1
  24. package/dist/src/gateway/server.js +24 -0
  25. package/dist/src/gateway/server.js.map +1 -1
  26. package/dist/src/gateway/state.d.ts +1 -1
  27. package/dist/src/index.d.ts +1 -1
  28. package/dist/src/index.js +1 -1
  29. package/docs/configuration.md +70 -0
  30. package/docs/development.md +17 -0
  31. package/docs/images/tui-in-scope.png +0 -0
  32. package/docs/images/tui-in-scope.svg +24 -0
  33. package/docs/images/tui-out-of-scope.png +0 -0
  34. package/docs/images/tui-out-of-scope.svg +25 -0
  35. package/docs/images/tui-policy-blocks.png +0 -0
  36. package/docs/images/tui-policy-blocks.svg +24 -0
  37. package/docs/integrations.md +74 -0
  38. package/docs/managed-gateway.md +4 -0
  39. package/docs/release-readiness-0.2.6.md +27 -0
  40. package/docs/security.md +33 -0
  41. package/docs/superpowers/plans/2026-03-26-mcp-scope-proxy.md +3051 -0
  42. package/docs/superpowers/plans/2026-03-28-tui-monitor.md +863 -0
  43. package/docs/superpowers/plans/2026-03-29-manifest-builder.md +3429 -0
  44. package/docs/superpowers/specs/2026-03-28-tui-monitor-design.md +138 -0
  45. package/docs/superpowers/specs/2026-03-29-manifest-builder-design.md +248 -0
  46. package/docs/tui.md +27 -0
  47. package/package.json +4 -2
@@ -0,0 +1,3051 @@
1
+ # MCP Scope Enforcement Proxy — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a production-grade MCP middleware proxy that intercepts AI agent tool calls, validates targets against a pentest engagement scope manifest, blocks out-of-scope calls, and produces a full audit trail to local files and cloud logging (AWS/Azure/GCP).
6
+
7
+ **Architecture:** The proxy sits between an AI agent and upstream MCP servers. It intercepts `tools/call` JSON-RPC messages, extracts targets from tool parameters, validates them against an in-memory scope manifest (IP/CIDR, domain, URL, technique, time window checks), and either forwards allowed calls or returns MCP error responses for blocked calls. Every decision is audit-logged.
8
+
9
+ **Tech Stack:** TypeScript, Node.js 20+, `@modelcontextprotocol/sdk`, `zod` for validation, `vitest` for testing, `ip-cidr`/`netmask` for CIDR matching, `dns/promises` (stdlib) for resolution, cloud SDKs (`@aws-sdk/client-cloudwatch-logs`, `@azure/monitor-ingestion`, `@google-cloud/logging`) as optional peer dependencies.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ mcp-scope-proxy/
17
+ ├── package.json
18
+ ├── tsconfig.json
19
+ ├── vitest.config.ts
20
+ ├── Dockerfile
21
+ ├── .env.example
22
+ ├── .gitignore
23
+ ├── bin/
24
+ │ └── mcp-scope-proxy.ts # CLI entry point (hashbang, arg parsing)
25
+ ├── src/
26
+ │ ├── index.ts # Public API exports
27
+ │ ├── proxy.ts # Core proxy: intercepts tools/call, delegates to validator
28
+ │ ├── manifest.ts # Scope manifest loading (file, env, cloud secrets)
29
+ │ ├── manifest-schema.ts # Zod schema for scope manifest
30
+ │ ├── validator.ts # Scope validation engine (orchestrates all checks)
31
+ │ ├── extractor.ts # Target extraction from tool parameters
32
+ │ ├── dns-resolver.ts # DNS resolution with TTL-aware caching
33
+ │ ├── ip-matcher.ts # IP/CIDR matching utilities
34
+ │ ├── domain-matcher.ts # Domain/wildcard matching utilities
35
+ │ ├── technique-checker.ts # Technique category blocking
36
+ │ ├── sanitizer.ts # Parameter sanitization for audit logs
37
+ │ ├── config.ts # Environment variable parsing with defaults
38
+ │ ├── types.ts # Shared TypeScript types and interfaces
39
+ │ ├── audit/
40
+ │ │ ├── index.ts # AuditLogger facade (dispatches to backends)
41
+ │ │ ├── local.ts # Local JSONL file writer
42
+ │ │ ├── cloudwatch.ts # AWS CloudWatch Logs integration
43
+ │ │ ├── azure-monitor.ts # Azure Monitor/Log Analytics integration
44
+ │ │ └── gcp-logging.ts # GCP Cloud Logging integration
45
+ │ └── transports/
46
+ │ ├── stdio.ts # stdio transport proxy
47
+ │ └── http.ts # Streamable HTTP transport proxy
48
+ ├── tests/
49
+ │ ├── unit/
50
+ │ │ ├── validator.test.ts
51
+ │ │ ├── extractor.test.ts
52
+ │ │ ├── dns-resolver.test.ts
53
+ │ │ ├── ip-matcher.test.ts
54
+ │ │ ├── domain-matcher.test.ts
55
+ │ │ ├── technique-checker.test.ts
56
+ │ │ ├── sanitizer.test.ts
57
+ │ │ ├── manifest.test.ts
58
+ │ │ └── audit-local.test.ts
59
+ │ ├── integration/
60
+ │ │ └── proxy.test.ts
61
+ │ └── fixtures/
62
+ │ ├── scope-valid.json
63
+ │ ├── scope-expired.json
64
+ │ └── tool-calls.json
65
+ ├── examples/
66
+ │ ├── scope-manifest.json
67
+ │ ├── technique-map.json
68
+ │ └── claude-desktop-config.json
69
+ └── scripts/
70
+ └── test-proxy.ts # Manual integration test script
71
+ ```
72
+
73
+ ### File Responsibilities
74
+
75
+ | File | Responsibility |
76
+ |------|---------------|
77
+ | `bin/mcp-scope-proxy.ts` | CLI entry: parse args, load config, start proxy |
78
+ | `src/proxy.ts` | Core proxy loop: receive MCP messages, intercept `tools/call`, delegate validation, forward or block |
79
+ | `src/manifest.ts` | Load scope manifest from file/env/AWS/Azure/GCP, parse and validate with Zod |
80
+ | `src/manifest-schema.ts` | Zod schema definition for the scope manifest JSON |
81
+ | `src/validator.ts` | Orchestrate validation: technique check → time window → extract target → exclusion → authorization |
82
+ | `src/extractor.ts` | Extract target (IP/hostname/URL) from tool call parameters using tool-specific and fallback rules |
83
+ | `src/dns-resolver.ts` | Resolve hostnames to IPs (A+AAAA), cache with TTL, fail closed on error |
84
+ | `src/ip-matcher.ts` | Check if an IP is within CIDR ranges, detect RFC-1918/metadata IPs |
85
+ | `src/domain-matcher.ts` | Wildcard domain matching (`*.acme.com`), exact match, case-insensitive |
86
+ | `src/technique-checker.ts` | Map tool names to technique categories, check against blocked list |
87
+ | `src/sanitizer.ts` | Redact sensitive parameter values (passwords, tokens, keys) before logging |
88
+ | `src/config.ts` | Read all env vars, apply defaults, validate required config |
89
+ | `src/types.ts` | TypeScript interfaces: `ScopeManifest`, `AuditEntry`, `ValidationResult`, `Decision` |
90
+ | `src/audit/index.ts` | Facade: dispatch audit entries to all configured backends |
91
+ | `src/audit/local.ts` | Append JSONL to local file per engagement |
92
+ | `src/audit/cloudwatch.ts` | Batch + flush to AWS CloudWatch Logs |
93
+ | `src/audit/azure-monitor.ts` | Batch + flush to Azure Monitor via DCR |
94
+ | `src/audit/gcp-logging.ts` | Batch + flush to GCP Cloud Logging |
95
+ | `src/transports/stdio.ts` | Proxy MCP over stdio (spawn upstream as subprocess or connect via pipe) |
96
+ | `src/transports/http.ts` | Proxy MCP over Streamable HTTP (listen on PROXY_PORT, forward to UPSTREAM_MCP_URL) |
97
+
98
+ ---
99
+
100
+ ## Task 1: Project Scaffolding
101
+
102
+ **Files:**
103
+ - Create: `package.json`
104
+ - Create: `tsconfig.json`
105
+ - Create: `vitest.config.ts`
106
+ - Create: `.gitignore`
107
+ - Create: `.env.example`
108
+
109
+ - [ ] **Step 1: Create package.json**
110
+
111
+ ```json
112
+ {
113
+ "name": "mcp-scope-proxy",
114
+ "version": "0.1.0",
115
+ "description": "MCP Scope Enforcement Proxy — enforce penetration testing engagement scope for AI agents",
116
+ "type": "module",
117
+ "bin": {
118
+ "mcp-scope-proxy": "./dist/bin/mcp-scope-proxy.js"
119
+ },
120
+ "main": "./dist/src/index.js",
121
+ "types": "./dist/src/index.d.ts",
122
+ "scripts": {
123
+ "build": "tsc",
124
+ "dev": "tsc --watch",
125
+ "test": "vitest run",
126
+ "test:watch": "vitest",
127
+ "lint": "tsc --noEmit",
128
+ "start": "node dist/bin/mcp-scope-proxy.js"
129
+ },
130
+ "keywords": ["mcp", "proxy", "pentest", "scope-enforcement", "audit", "ai-agent"],
131
+ "author": "IntegSec",
132
+ "license": "MIT",
133
+ "dependencies": {
134
+ "@modelcontextprotocol/sdk": "^1.12.0",
135
+ "zod": "^3.24.0",
136
+ "ip-cidr": "^4.0.1",
137
+ "dotenv": "^16.4.0"
138
+ },
139
+ "devDependencies": {
140
+ "@types/node": "^20.0.0",
141
+ "typescript": "^5.7.0",
142
+ "vitest": "^3.0.0"
143
+ },
144
+ "optionalDependencies": {
145
+ "@aws-sdk/client-cloudwatch-logs": "^3.700.0",
146
+ "@azure/monitor-ingestion": "^1.1.0",
147
+ "@azure/identity": "^4.5.0",
148
+ "@google-cloud/logging": "^11.0.0"
149
+ },
150
+ "engines": {
151
+ "node": ">=20.0.0"
152
+ }
153
+ }
154
+ ```
155
+
156
+ - [ ] **Step 2: Create tsconfig.json**
157
+
158
+ ```json
159
+ {
160
+ "compilerOptions": {
161
+ "target": "ES2022",
162
+ "module": "Node16",
163
+ "moduleResolution": "Node16",
164
+ "lib": ["ES2022"],
165
+ "outDir": "./dist",
166
+ "rootDir": ".",
167
+ "strict": true,
168
+ "esModuleInterop": true,
169
+ "skipLibCheck": true,
170
+ "forceConsistentCasingInFileNames": true,
171
+ "resolveJsonModule": true,
172
+ "declaration": true,
173
+ "declarationMap": true,
174
+ "sourceMap": true
175
+ },
176
+ "include": ["src/**/*", "bin/**/*"],
177
+ "exclude": ["node_modules", "dist", "tests"]
178
+ }
179
+ ```
180
+
181
+ - [ ] **Step 3: Create vitest.config.ts**
182
+
183
+ ```typescript
184
+ import { defineConfig } from "vitest/config";
185
+
186
+ export default defineConfig({
187
+ test: {
188
+ globals: true,
189
+ include: ["tests/**/*.test.ts"],
190
+ },
191
+ });
192
+ ```
193
+
194
+ - [ ] **Step 4: Create .gitignore**
195
+
196
+ ```
197
+ node_modules/
198
+ dist/
199
+ .env
200
+ audit/
201
+ *.jsonl
202
+ .DS_Store
203
+ ```
204
+
205
+ - [ ] **Step 5: Create .env.example**
206
+
207
+ ```bash
208
+ # Required — scope manifest source (use ONE)
209
+ SCOPE_MANIFEST_PATH=./scope.json
210
+ # SCOPE_MANIFEST_JSON='{"engagement_id":"..."}'
211
+ # SCOPE_MANIFEST_SECRET_ARN=arn:aws:secretsmanager:us-east-1:123456789012:secret:scope
212
+ # SCOPE_MANIFEST_KEYVAULT_URI=https://vault.azure.net/secrets/scope/latest
213
+ # SCOPE_MANIFEST_GCP_SECRET=projects/123/secrets/scope/versions/latest
214
+
215
+ # Transport
216
+ MCP_TRANSPORT=stdio
217
+ # UPSTREAM_MCP_URL=http://localhost:8080
218
+ # UPSTREAM_MCP_COMMAND=npx
219
+ # UPSTREAM_MCP_ARGS=-y,@anthropic/kali-mcp
220
+ # PROXY_PORT=9090
221
+
222
+ # Audit logging
223
+ AUDIT_LOG_PATH=./audit/
224
+
225
+ # AWS CloudWatch (optional)
226
+ # AWS_LOG_GROUP=/integsec/mcp-proxy
227
+ # AWS_REGION=us-east-1
228
+
229
+ # Azure Monitor (optional)
230
+ # AZURE_LOG_WORKSPACE_ID=...
231
+ # AZURE_DCR_IMMUTABLE_ID=...
232
+ # AZURE_DCR_STREAM_NAME=Custom-MCPProxyAudit_CL
233
+
234
+ # GCP Cloud Logging (optional)
235
+ # GOOGLE_CLOUD_PROJECT=my-project
236
+
237
+ # Optional
238
+ # TECHNIQUE_MAP_PATH=./technique_map.json
239
+ # DNS_CACHE_TTL=60
240
+ # LOG_LEVEL=INFO
241
+ ```
242
+
243
+ - [ ] **Step 6: Install dependencies**
244
+
245
+ Run: `npm install`
246
+ Expected: `node_modules/` created, lockfile generated.
247
+
248
+ - [ ] **Step 7: Verify build toolchain**
249
+
250
+ Create `src/index.ts` with placeholder:
251
+ ```typescript
252
+ export const VERSION = "0.1.0";
253
+ ```
254
+
255
+ Run: `npx tsc --noEmit`
256
+ Expected: No errors.
257
+
258
+ - [ ] **Step 8: Verify test toolchain**
259
+
260
+ Create `tests/unit/smoke.test.ts`:
261
+ ```typescript
262
+ import { describe, it, expect } from "vitest";
263
+ import { VERSION } from "../../src/index.js";
264
+
265
+ describe("smoke test", () => {
266
+ it("exports version", () => {
267
+ expect(VERSION).toBe("0.1.0");
268
+ });
269
+ });
270
+ ```
271
+
272
+ Run: `npx vitest run`
273
+ Expected: 1 test passes.
274
+
275
+ - [ ] **Step 9: Commit**
276
+
277
+ ```bash
278
+ git add -A
279
+ git commit -m "chore: scaffold project with TypeScript, vitest, and dependencies"
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Task 2: Types and Manifest Schema
285
+
286
+ **Files:**
287
+ - Create: `src/types.ts`
288
+ - Create: `src/manifest-schema.ts`
289
+ - Test: `tests/unit/manifest.test.ts` (schema validation portion)
290
+
291
+ - [ ] **Step 1: Write failing tests for manifest schema**
292
+
293
+ Create `tests/unit/manifest-schema.test.ts`:
294
+
295
+ ```typescript
296
+ import { describe, it, expect } from "vitest";
297
+ import { ScopeManifestSchema } from "../../src/manifest-schema.js";
298
+
299
+ describe("ScopeManifestSchema", () => {
300
+ const validManifest = {
301
+ engagement_id: "ENG-2025-0142",
302
+ client: "Acme Corp",
303
+ operator: "operator@integsec.com",
304
+ authorized_targets: {
305
+ ip_ranges: ["10.10.10.0/24", "203.0.113.0/28"],
306
+ domains: ["*.acme.com", "acme-staging.example.com"],
307
+ urls: ["https://app.acme.com", "https://api.acme.com"],
308
+ cloud_accounts: ["aws:123456789012", "azure:sub-xxxx"],
309
+ },
310
+ excluded_targets: ["203.0.113.5", "hr.acme.com"],
311
+ authorized_techniques: ["recon", "web_app", "api_testing"],
312
+ excluded_techniques: ["dos", "destructive", "social_engineering"],
313
+ engagement_window: {
314
+ start: "2026-03-26T08:00:00Z",
315
+ end: "2026-04-09T17:00:00Z",
316
+ },
317
+ };
318
+
319
+ it("parses a valid manifest", () => {
320
+ const result = ScopeManifestSchema.safeParse(validManifest);
321
+ expect(result.success).toBe(true);
322
+ });
323
+
324
+ it("rejects manifest missing engagement_id", () => {
325
+ const { engagement_id, ...rest } = validManifest;
326
+ const result = ScopeManifestSchema.safeParse(rest);
327
+ expect(result.success).toBe(false);
328
+ });
329
+
330
+ it("rejects manifest with invalid engagement_window dates", () => {
331
+ const bad = { ...validManifest, engagement_window: { start: "not-a-date", end: "also-bad" } };
332
+ const result = ScopeManifestSchema.safeParse(bad);
333
+ expect(result.success).toBe(false);
334
+ });
335
+
336
+ it("allows empty optional arrays", () => {
337
+ const minimal = {
338
+ engagement_id: "ENG-001",
339
+ client: "Test",
340
+ operator: "test@test.com",
341
+ authorized_targets: {
342
+ ip_ranges: [],
343
+ domains: [],
344
+ urls: [],
345
+ cloud_accounts: [],
346
+ },
347
+ excluded_targets: [],
348
+ authorized_techniques: [],
349
+ excluded_techniques: [],
350
+ engagement_window: {
351
+ start: "2026-01-01T00:00:00Z",
352
+ end: "2026-12-31T23:59:59Z",
353
+ },
354
+ };
355
+ const result = ScopeManifestSchema.safeParse(minimal);
356
+ expect(result.success).toBe(true);
357
+ });
358
+ });
359
+ ```
360
+
361
+ - [ ] **Step 2: Run tests to verify they fail**
362
+
363
+ Run: `npx vitest run tests/unit/manifest-schema.test.ts`
364
+ Expected: FAIL — cannot find module `../../src/manifest-schema.js`
365
+
366
+ - [ ] **Step 3: Implement types.ts**
367
+
368
+ Create `src/types.ts`:
369
+
370
+ ```typescript
371
+ export type Decision =
372
+ | "ALLOWED"
373
+ | "BLOCKED_EXCLUDED_TARGET"
374
+ | "BLOCKED_NOT_IN_SCOPE"
375
+ | "BLOCKED_OUTSIDE_WINDOW"
376
+ | "BLOCKED_TECHNIQUE"
377
+ | "BLOCKED_DNS_FAILED"
378
+ | "ALLOWED_NO_TARGET";
379
+
380
+ export interface ValidationResult {
381
+ decision: Decision;
382
+ reason: string;
383
+ matchedScopeItem?: string;
384
+ extractedTarget?: string;
385
+ resolvedIps?: string[];
386
+ durationMs: number;
387
+ }
388
+
389
+ export interface AuditEntry {
390
+ timestamp: string;
391
+ engagement_id: string;
392
+ client: string;
393
+ operator: string;
394
+ tool_name: string;
395
+ tool_parameters: Record<string, unknown>;
396
+ extracted_target: string | null;
397
+ resolved_ips: string[];
398
+ decision: Decision;
399
+ decision_reason: string;
400
+ matched_scope_item: string | null;
401
+ duration_ms: number;
402
+ proxy_version: string;
403
+ }
404
+
405
+ export interface ScopeManifest {
406
+ engagement_id: string;
407
+ client: string;
408
+ operator: string;
409
+ authorized_targets: {
410
+ ip_ranges: string[];
411
+ domains: string[];
412
+ urls: string[];
413
+ cloud_accounts: string[];
414
+ };
415
+ excluded_targets: string[];
416
+ authorized_techniques: string[];
417
+ excluded_techniques: string[];
418
+ engagement_window: {
419
+ start: string;
420
+ end: string;
421
+ };
422
+ }
423
+ ```
424
+
425
+ - [ ] **Step 4: Implement manifest-schema.ts**
426
+
427
+ Create `src/manifest-schema.ts`:
428
+
429
+ ```typescript
430
+ import { z } from "zod";
431
+
432
+ const isoDateString = z.string().refine(
433
+ (val) => !isNaN(Date.parse(val)),
434
+ { message: "Must be a valid ISO 8601 date string" }
435
+ );
436
+
437
+ export const ScopeManifestSchema = z.object({
438
+ engagement_id: z.string().min(1),
439
+ client: z.string().min(1),
440
+ operator: z.string().min(1),
441
+ authorized_targets: z.object({
442
+ ip_ranges: z.array(z.string()),
443
+ domains: z.array(z.string()),
444
+ urls: z.array(z.string()),
445
+ cloud_accounts: z.array(z.string()),
446
+ }),
447
+ excluded_targets: z.array(z.string()),
448
+ authorized_techniques: z.array(z.string()),
449
+ excluded_techniques: z.array(z.string()),
450
+ engagement_window: z.object({
451
+ start: isoDateString,
452
+ end: isoDateString,
453
+ }),
454
+ });
455
+
456
+ export type ScopeManifest = z.infer<typeof ScopeManifestSchema>;
457
+ ```
458
+
459
+ - [ ] **Step 5: Run tests to verify they pass**
460
+
461
+ Run: `npx vitest run tests/unit/manifest-schema.test.ts`
462
+ Expected: 4 tests pass.
463
+
464
+ - [ ] **Step 6: Commit**
465
+
466
+ ```bash
467
+ git add src/types.ts src/manifest-schema.ts tests/unit/manifest-schema.test.ts
468
+ git commit -m "feat: add types and Zod schema for scope manifest"
469
+ ```
470
+
471
+ ---
472
+
473
+ ## Task 3: IP Matcher
474
+
475
+ **Files:**
476
+ - Create: `src/ip-matcher.ts`
477
+ - Test: `tests/unit/ip-matcher.test.ts`
478
+
479
+ - [ ] **Step 1: Write failing tests**
480
+
481
+ Create `tests/unit/ip-matcher.test.ts`:
482
+
483
+ ```typescript
484
+ import { describe, it, expect } from "vitest";
485
+ import { isIpInCidr, isRfc1918, isCloudMetadata, isIpInAnyRange } from "../../src/ip-matcher.js";
486
+
487
+ describe("isIpInCidr", () => {
488
+ it("matches IP within CIDR range", () => {
489
+ expect(isIpInCidr("10.10.10.5", "10.10.10.0/24")).toBe(true);
490
+ });
491
+
492
+ it("rejects IP outside CIDR range", () => {
493
+ expect(isIpInCidr("10.10.11.1", "10.10.10.0/24")).toBe(false);
494
+ });
495
+
496
+ it("matches exact IP as /32", () => {
497
+ expect(isIpInCidr("203.0.113.5", "203.0.113.5/32")).toBe(true);
498
+ });
499
+
500
+ it("handles single IP (no CIDR notation) as exact match", () => {
501
+ expect(isIpInCidr("203.0.113.5", "203.0.113.5")).toBe(true);
502
+ });
503
+
504
+ it("rejects invalid IP gracefully", () => {
505
+ expect(isIpInCidr("not-an-ip", "10.0.0.0/8")).toBe(false);
506
+ });
507
+ });
508
+
509
+ describe("isRfc1918", () => {
510
+ it("detects 10.x.x.x", () => {
511
+ expect(isRfc1918("10.0.0.1")).toBe(true);
512
+ });
513
+
514
+ it("detects 172.16.x.x", () => {
515
+ expect(isRfc1918("172.16.0.1")).toBe(true);
516
+ });
517
+
518
+ it("detects 192.168.x.x", () => {
519
+ expect(isRfc1918("192.168.1.1")).toBe(true);
520
+ });
521
+
522
+ it("detects link-local 169.254.x.x", () => {
523
+ expect(isRfc1918("169.254.1.1")).toBe(true);
524
+ });
525
+
526
+ it("does not flag public IPs", () => {
527
+ expect(isRfc1918("203.0.113.10")).toBe(false);
528
+ });
529
+ });
530
+
531
+ describe("isCloudMetadata", () => {
532
+ it("detects AWS/GCP metadata IP", () => {
533
+ expect(isCloudMetadata("169.254.169.254")).toBe(true);
534
+ });
535
+
536
+ it("detects AWS IPv6 metadata", () => {
537
+ expect(isCloudMetadata("fd00:ec2::254")).toBe(true);
538
+ });
539
+
540
+ it("does not flag normal IPs", () => {
541
+ expect(isCloudMetadata("10.0.0.1")).toBe(false);
542
+ });
543
+ });
544
+
545
+ describe("isIpInAnyRange", () => {
546
+ const ranges = ["10.10.10.0/24", "203.0.113.0/28"];
547
+
548
+ it("matches IP in first range", () => {
549
+ expect(isIpInAnyRange("10.10.10.50", ranges)).toBe(true);
550
+ });
551
+
552
+ it("matches IP in second range", () => {
553
+ expect(isIpInAnyRange("203.0.113.10", ranges)).toBe(true);
554
+ });
555
+
556
+ it("rejects IP not in any range", () => {
557
+ expect(isIpInAnyRange("192.168.1.1", ranges)).toBe(false);
558
+ });
559
+ });
560
+ ```
561
+
562
+ - [ ] **Step 2: Run tests to verify they fail**
563
+
564
+ Run: `npx vitest run tests/unit/ip-matcher.test.ts`
565
+ Expected: FAIL — cannot find module
566
+
567
+ - [ ] **Step 3: Implement ip-matcher.ts**
568
+
569
+ Create `src/ip-matcher.ts`:
570
+
571
+ ```typescript
572
+ import IPCIDR from "ip-cidr";
573
+
574
+ const RFC1918_RANGES = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16"];
575
+ const CLOUD_METADATA = ["169.254.169.254", "fd00:ec2::254"];
576
+
577
+ export function isIpInCidr(ip: string, cidr: string): boolean {
578
+ try {
579
+ // Handle bare IP (no slash) as exact match
580
+ if (!cidr.includes("/")) {
581
+ return ip === cidr;
582
+ }
583
+ const range = new IPCIDR(cidr);
584
+ if (!range.isValid()) return false;
585
+ return range.contains(ip);
586
+ } catch {
587
+ return false;
588
+ }
589
+ }
590
+
591
+ export function isIpInAnyRange(ip: string, ranges: string[]): boolean {
592
+ return ranges.some((range) => isIpInCidr(ip, range));
593
+ }
594
+
595
+ export function isRfc1918(ip: string): boolean {
596
+ return isIpInAnyRange(ip, RFC1918_RANGES);
597
+ }
598
+
599
+ export function isCloudMetadata(ip: string): boolean {
600
+ return CLOUD_METADATA.includes(ip);
601
+ }
602
+ ```
603
+
604
+ - [ ] **Step 4: Run tests to verify they pass**
605
+
606
+ Run: `npx vitest run tests/unit/ip-matcher.test.ts`
607
+ Expected: All tests pass.
608
+
609
+ - [ ] **Step 5: Commit**
610
+
611
+ ```bash
612
+ git add src/ip-matcher.ts tests/unit/ip-matcher.test.ts
613
+ git commit -m "feat: add IP/CIDR matching with RFC-1918 and cloud metadata detection"
614
+ ```
615
+
616
+ ---
617
+
618
+ ## Task 4: Domain Matcher
619
+
620
+ **Files:**
621
+ - Create: `src/domain-matcher.ts`
622
+ - Test: `tests/unit/domain-matcher.test.ts`
623
+
624
+ - [ ] **Step 1: Write failing tests**
625
+
626
+ Create `tests/unit/domain-matcher.test.ts`:
627
+
628
+ ```typescript
629
+ import { describe, it, expect } from "vitest";
630
+ import { matchesDomain, matchesAnyDomain } from "../../src/domain-matcher.js";
631
+
632
+ describe("matchesDomain", () => {
633
+ it("matches exact domain", () => {
634
+ expect(matchesDomain("acme-staging.example.com", "acme-staging.example.com")).toBe(true);
635
+ });
636
+
637
+ it("matches case-insensitively", () => {
638
+ expect(matchesDomain("ACME-STAGING.Example.COM", "acme-staging.example.com")).toBe(true);
639
+ });
640
+
641
+ it("wildcard matches single subdomain", () => {
642
+ expect(matchesDomain("foo.acme.com", "*.acme.com")).toBe(true);
643
+ });
644
+
645
+ it("wildcard does NOT match apex domain", () => {
646
+ expect(matchesDomain("acme.com", "*.acme.com")).toBe(false);
647
+ });
648
+
649
+ it("wildcard does NOT match two levels deep", () => {
650
+ expect(matchesDomain("foo.bar.acme.com", "*.acme.com")).toBe(false);
651
+ });
652
+
653
+ it("strips trailing dots", () => {
654
+ expect(matchesDomain("foo.acme.com.", "*.acme.com")).toBe(true);
655
+ });
656
+
657
+ it("rejects non-matching domain", () => {
658
+ expect(matchesDomain("evil.com", "*.acme.com")).toBe(false);
659
+ });
660
+ });
661
+
662
+ describe("matchesAnyDomain", () => {
663
+ const patterns = ["*.acme.com", "acme-staging.example.com"];
664
+
665
+ it("matches wildcard pattern", () => {
666
+ expect(matchesAnyDomain("app.acme.com", patterns)).toBe(true);
667
+ });
668
+
669
+ it("matches exact pattern", () => {
670
+ expect(matchesAnyDomain("acme-staging.example.com", patterns)).toBe(true);
671
+ });
672
+
673
+ it("rejects no match", () => {
674
+ expect(matchesAnyDomain("evil.com", patterns)).toBe(false);
675
+ });
676
+ });
677
+ ```
678
+
679
+ - [ ] **Step 2: Run tests to verify they fail**
680
+
681
+ Run: `npx vitest run tests/unit/domain-matcher.test.ts`
682
+ Expected: FAIL
683
+
684
+ - [ ] **Step 3: Implement domain-matcher.ts**
685
+
686
+ Create `src/domain-matcher.ts`:
687
+
688
+ ```typescript
689
+ export function matchesDomain(hostname: string, pattern: string): boolean {
690
+ // Normalize: lowercase, strip trailing dot
691
+ const h = hostname.toLowerCase().replace(/\.$/, "");
692
+ const p = pattern.toLowerCase().replace(/\.$/, "");
693
+
694
+ if (p.startsWith("*.")) {
695
+ // Wildcard: *.acme.com matches foo.acme.com but NOT acme.com or foo.bar.acme.com
696
+ const base = p.slice(2); // "acme.com"
697
+ if (h === base) return false; // apex — no match
698
+ const prefix = h.slice(0, -(base.length + 1)); // everything before ".acme.com"
699
+ if (!h.endsWith("." + base)) return false;
700
+ // prefix must be a single label (no dots)
701
+ return prefix.length > 0 && !prefix.includes(".");
702
+ }
703
+
704
+ return h === p;
705
+ }
706
+
707
+ export function matchesAnyDomain(hostname: string, patterns: string[]): boolean {
708
+ return patterns.some((p) => matchesDomain(hostname, p));
709
+ }
710
+ ```
711
+
712
+ - [ ] **Step 4: Run tests to verify they pass**
713
+
714
+ Run: `npx vitest run tests/unit/domain-matcher.test.ts`
715
+ Expected: All tests pass.
716
+
717
+ - [ ] **Step 5: Commit**
718
+
719
+ ```bash
720
+ git add src/domain-matcher.ts tests/unit/domain-matcher.test.ts
721
+ git commit -m "feat: add domain/wildcard matching with case-insensitive comparison"
722
+ ```
723
+
724
+ ---
725
+
726
+ ## Task 5: Target Extractor
727
+
728
+ **Files:**
729
+ - Create: `src/extractor.ts`
730
+ - Test: `tests/unit/extractor.test.ts`
731
+
732
+ - [ ] **Step 1: Write failing tests**
733
+
734
+ Create `tests/unit/extractor.test.ts`:
735
+
736
+ ```typescript
737
+ import { describe, it, expect } from "vitest";
738
+ import { extractTarget, ExtractionResult } from "../../src/extractor.js";
739
+
740
+ describe("extractTarget", () => {
741
+ it("extracts target from nmap_scan", () => {
742
+ const result = extractTarget("nmap_scan", { target: "203.0.113.10", ports: "1-1024" });
743
+ expect(result).toEqual({ target: "203.0.113.10", source: "target", usedFallback: false });
744
+ });
745
+
746
+ it("extracts host from nmap", () => {
747
+ const result = extractTarget("nmap", { host: "10.10.10.1" });
748
+ expect(result).toEqual({ target: "10.10.10.1", source: "host", usedFallback: false });
749
+ });
750
+
751
+ it("extracts hostname from burp_scan URL", () => {
752
+ const result = extractTarget("burp_scan", { url: "https://app.acme.com/api/v1" });
753
+ expect(result).toEqual({ target: "app.acme.com", source: "url", usedFallback: false });
754
+ });
755
+
756
+ it("extracts hostname from sqlmap URL", () => {
757
+ const result = extractTarget("sqlmap", { url: "http://test.acme.com/login?id=1" });
758
+ expect(result).toEqual({ target: "test.acme.com", source: "url", usedFallback: false });
759
+ });
760
+
761
+ it("extracts rhost from metasploit tool", () => {
762
+ const result = extractTarget("msf_exploit", { rhost: "10.10.10.5", payload: "reverse_tcp" });
763
+ expect(result).toEqual({ target: "10.10.10.5", source: "rhost", usedFallback: false });
764
+ });
765
+
766
+ it("handles nuclei with targets array (takes first)", () => {
767
+ const result = extractTarget("nuclei", { targets: ["10.10.10.1", "10.10.10.2"] });
768
+ expect(result).toEqual({ target: "10.10.10.1", source: "targets", usedFallback: false });
769
+ });
770
+
771
+ it("falls back to host param for unknown tool", () => {
772
+ const result = extractTarget("custom_tool", { host: "192.168.1.1", mode: "fast" });
773
+ expect(result).toEqual({ target: "192.168.1.1", source: "host", usedFallback: true });
774
+ });
775
+
776
+ it("falls back to url param for unknown tool", () => {
777
+ const result = extractTarget("custom_scanner", { url: "https://example.com/path" });
778
+ expect(result).toEqual({ target: "example.com", source: "url", usedFallback: true });
779
+ });
780
+
781
+ it("returns null for tool with no recognizable target param", () => {
782
+ const result = extractTarget("list_files", { path: "/tmp", recursive: true });
783
+ expect(result).toBeNull();
784
+ });
785
+
786
+ it("strips credentials from URL before extracting hostname", () => {
787
+ const result = extractTarget("curl", { url: "https://user:pass@secret.acme.com/api" });
788
+ expect(result).toEqual({ target: "secret.acme.com", source: "url", usedFallback: false });
789
+ });
790
+ });
791
+ ```
792
+
793
+ - [ ] **Step 2: Run tests to verify they fail**
794
+
795
+ Run: `npx vitest run tests/unit/extractor.test.ts`
796
+ Expected: FAIL
797
+
798
+ - [ ] **Step 3: Implement extractor.ts**
799
+
800
+ Create `src/extractor.ts`:
801
+
802
+ ```typescript
803
+ export interface ExtractionResult {
804
+ target: string;
805
+ source: string;
806
+ usedFallback: boolean;
807
+ }
808
+
809
+ // Tool name patterns → ordered list of parameter names to check
810
+ const TOOL_PARAM_MAP: Record<string, string[]> = {
811
+ nmap: ["target", "host", "hosts"],
812
+ nmap_scan: ["target", "host", "hosts"],
813
+ burp_scan: ["url", "target_url", "base_url"],
814
+ burp_active_scan: ["url", "target_url", "base_url"],
815
+ sqlmap: ["url", "target"],
816
+ curl: ["url", "uri"],
817
+ http_request: ["url", "uri"],
818
+ nikto: ["host", "target", "url"],
819
+ gobuster: ["url", "target"],
820
+ ffuf: ["url", "target"],
821
+ dirb: ["url", "target"],
822
+ netcat: ["host", "target"],
823
+ nc: ["host", "target"],
824
+ ping: ["host", "target"],
825
+ traceroute: ["host", "target"],
826
+ nuclei: ["target", "targets", "url"],
827
+ };
828
+
829
+ // Metasploit-style tools use rhost/rhosts
830
+ const MSF_PARAMS = ["rhost", "rhosts", "target"];
831
+
832
+ // Fallback parameter names for unknown tools
833
+ const FALLBACK_PARAMS = ["target", "host", "url", "rhost", "destination", "uri"];
834
+
835
+ function extractHostnameFromUrl(urlStr: string): string | null {
836
+ try {
837
+ const url = new URL(urlStr);
838
+ return url.hostname;
839
+ } catch {
840
+ return null;
841
+ }
842
+ }
843
+
844
+ function isUrl(value: string): boolean {
845
+ return /^https?:\/\//i.test(value);
846
+ }
847
+
848
+ function resolveParamValue(params: Record<string, unknown>, paramNames: string[]): { value: string; source: string } | null {
849
+ for (const name of paramNames) {
850
+ const val = params[name];
851
+ if (typeof val === "string" && val.length > 0) {
852
+ return { value: val, source: name };
853
+ }
854
+ if (Array.isArray(val) && val.length > 0 && typeof val[0] === "string") {
855
+ return { value: val[0], source: name };
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+
861
+ export function extractTarget(toolName: string, params: Record<string, unknown>): ExtractionResult | null {
862
+ const normalizedTool = toolName.toLowerCase();
863
+
864
+ // Check for metasploit-style tools
865
+ const isMsf = normalizedTool.startsWith("msf") || normalizedTool === "metasploit";
866
+ const paramList = isMsf ? MSF_PARAMS : TOOL_PARAM_MAP[normalizedTool];
867
+
868
+ let usedFallback = false;
869
+ let resolved: { value: string; source: string } | null = null;
870
+
871
+ if (paramList) {
872
+ resolved = resolveParamValue(params, paramList);
873
+ }
874
+
875
+ if (!resolved) {
876
+ // Fallback for unknown tools
877
+ resolved = resolveParamValue(params, FALLBACK_PARAMS);
878
+ if (resolved) usedFallback = true;
879
+ }
880
+
881
+ if (!resolved) return null;
882
+
883
+ // If the value looks like a URL, extract the hostname
884
+ let target = resolved.value;
885
+ if (isUrl(target)) {
886
+ const hostname = extractHostnameFromUrl(target);
887
+ if (!hostname) return null;
888
+ target = hostname;
889
+ }
890
+
891
+ return { target, source: resolved.source, usedFallback };
892
+ }
893
+ ```
894
+
895
+ - [ ] **Step 4: Run tests to verify they pass**
896
+
897
+ Run: `npx vitest run tests/unit/extractor.test.ts`
898
+ Expected: All tests pass.
899
+
900
+ - [ ] **Step 5: Commit**
901
+
902
+ ```bash
903
+ git add src/extractor.ts tests/unit/extractor.test.ts
904
+ git commit -m "feat: add target extraction from MCP tool parameters"
905
+ ```
906
+
907
+ ---
908
+
909
+ ## Task 6: Technique Checker
910
+
911
+ **Files:**
912
+ - Create: `src/technique-checker.ts`
913
+ - Test: `tests/unit/technique-checker.test.ts`
914
+
915
+ - [ ] **Step 1: Write failing tests**
916
+
917
+ Create `tests/unit/technique-checker.test.ts`:
918
+
919
+ ```typescript
920
+ import { describe, it, expect } from "vitest";
921
+ import { TechniqueChecker } from "../../src/technique-checker.js";
922
+
923
+ describe("TechniqueChecker", () => {
924
+ const checker = new TechniqueChecker();
925
+
926
+ it("blocks hping3 as dos technique", () => {
927
+ expect(checker.getCategory("hping3")).toBe("dos");
928
+ });
929
+
930
+ it("blocks slowloris as dos technique", () => {
931
+ expect(checker.getCategory("slowloris")).toBe("dos");
932
+ });
933
+
934
+ it("blocks tool with flood in name as dos", () => {
935
+ expect(checker.getCategory("synflood")).toBe("dos");
936
+ });
937
+
938
+ it("blocks tool with dos in name", () => {
939
+ expect(checker.getCategory("my_dos_tool")).toBe("dos");
940
+ });
941
+
942
+ it("blocks gophish as social_engineering", () => {
943
+ expect(checker.getCategory("gophish")).toBe("social_engineering");
944
+ });
945
+
946
+ it("blocks setoolkit as social_engineering", () => {
947
+ expect(checker.getCategory("setoolkit")).toBe("social_engineering");
948
+ });
949
+
950
+ it("returns null for non-categorized tool", () => {
951
+ expect(checker.getCategory("nmap")).toBeNull();
952
+ });
953
+
954
+ it("isBlocked returns true when technique is excluded", () => {
955
+ expect(checker.isBlocked("hping3", ["dos"])).toBe(true);
956
+ });
957
+
958
+ it("isBlocked returns false when technique is not excluded", () => {
959
+ expect(checker.isBlocked("nmap", ["dos"])).toBe(false);
960
+ });
961
+
962
+ it("loads custom technique mappings", () => {
963
+ const custom = new TechniqueChecker({
964
+ custom_category: { exact: ["my_tool"], patterns: ["custom_"] },
965
+ });
966
+ expect(custom.getCategory("my_tool")).toBe("custom_category");
967
+ expect(custom.getCategory("custom_scanner")).toBe("custom_category");
968
+ });
969
+ });
970
+ ```
971
+
972
+ - [ ] **Step 2: Run tests to verify they fail**
973
+
974
+ Run: `npx vitest run tests/unit/technique-checker.test.ts`
975
+ Expected: FAIL
976
+
977
+ - [ ] **Step 3: Implement technique-checker.ts**
978
+
979
+ Create `src/technique-checker.ts`:
980
+
981
+ ```typescript
982
+ interface TechniqueMapping {
983
+ exact: string[];
984
+ patterns: string[];
985
+ }
986
+
987
+ type TechniqueMap = Record<string, TechniqueMapping>;
988
+
989
+ const DEFAULT_TECHNIQUE_MAP: TechniqueMap = {
990
+ dos: {
991
+ exact: ["hping3", "slowloris", "udpflood", "synflood"],
992
+ patterns: ["flood", "dos"],
993
+ },
994
+ destructive: {
995
+ exact: [],
996
+ patterns: ["destroy", "wipe", "wiper"],
997
+ },
998
+ social_engineering: {
999
+ exact: ["gophish", "setoolkit", "evilginx", "modlishka"],
1000
+ patterns: [],
1001
+ },
1002
+ };
1003
+
1004
+ export class TechniqueChecker {
1005
+ private map: TechniqueMap;
1006
+
1007
+ constructor(customMappings?: TechniqueMap) {
1008
+ this.map = { ...DEFAULT_TECHNIQUE_MAP };
1009
+ if (customMappings) {
1010
+ for (const [category, mapping] of Object.entries(customMappings)) {
1011
+ if (this.map[category]) {
1012
+ this.map[category] = {
1013
+ exact: [...this.map[category].exact, ...mapping.exact],
1014
+ patterns: [...this.map[category].patterns, ...mapping.patterns],
1015
+ };
1016
+ } else {
1017
+ this.map[category] = mapping;
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ getCategory(toolName: string): string | null {
1024
+ const normalized = toolName.toLowerCase();
1025
+ for (const [category, mapping] of Object.entries(this.map)) {
1026
+ if (mapping.exact.includes(normalized)) return category;
1027
+ if (mapping.patterns.some((p) => normalized.includes(p))) return category;
1028
+ }
1029
+ return null;
1030
+ }
1031
+
1032
+ isBlocked(toolName: string, excludedTechniques: string[]): boolean {
1033
+ const category = this.getCategory(toolName);
1034
+ if (!category) return false;
1035
+ return excludedTechniques.includes(category);
1036
+ }
1037
+ }
1038
+ ```
1039
+
1040
+ - [ ] **Step 4: Run tests to verify they pass**
1041
+
1042
+ Run: `npx vitest run tests/unit/technique-checker.test.ts`
1043
+ Expected: All tests pass.
1044
+
1045
+ - [ ] **Step 5: Commit**
1046
+
1047
+ ```bash
1048
+ git add src/technique-checker.ts tests/unit/technique-checker.test.ts
1049
+ git commit -m "feat: add technique category checker with configurable mappings"
1050
+ ```
1051
+
1052
+ ---
1053
+
1054
+ ## Task 7: Parameter Sanitizer
1055
+
1056
+ **Files:**
1057
+ - Create: `src/sanitizer.ts`
1058
+ - Test: `tests/unit/sanitizer.test.ts`
1059
+
1060
+ - [ ] **Step 1: Write failing tests**
1061
+
1062
+ Create `tests/unit/sanitizer.test.ts`:
1063
+
1064
+ ```typescript
1065
+ import { describe, it, expect } from "vitest";
1066
+ import { sanitizeParams } from "../../src/sanitizer.js";
1067
+
1068
+ describe("sanitizeParams", () => {
1069
+ it("redacts password field", () => {
1070
+ expect(sanitizeParams({ password: "secret123" })).toEqual({ password: "[REDACTED]" });
1071
+ });
1072
+
1073
+ it("redacts api_key field", () => {
1074
+ expect(sanitizeParams({ api_key: "sk-abc123" })).toEqual({ api_key: "[REDACTED]" });
1075
+ });
1076
+
1077
+ it("redacts token field", () => {
1078
+ expect(sanitizeParams({ token: "eyJhb..." })).toEqual({ token: "[REDACTED]" });
1079
+ });
1080
+
1081
+ it("redacts case-insensitively", () => {
1082
+ expect(sanitizeParams({ API_KEY: "key", Password: "pw" })).toEqual({
1083
+ API_KEY: "[REDACTED]",
1084
+ Password: "[REDACTED]",
1085
+ });
1086
+ });
1087
+
1088
+ it("redacts nested objects recursively", () => {
1089
+ const input = {
1090
+ config: { auth: { password: "secret", username: "admin" } },
1091
+ target: "10.0.0.1",
1092
+ };
1093
+ const result = sanitizeParams(input);
1094
+ expect(result).toEqual({
1095
+ config: { auth: { password: "[REDACTED]", username: "admin" } },
1096
+ target: "10.0.0.1",
1097
+ });
1098
+ });
1099
+
1100
+ it("redacts within arrays", () => {
1101
+ const input = { headers: [{ key: "Authorization", secret: "Bearer abc" }] };
1102
+ const result = sanitizeParams(input);
1103
+ expect(result).toEqual({ headers: [{ key: "Authorization", secret: "[REDACTED]" }] });
1104
+ });
1105
+
1106
+ it("passes through normal params unchanged", () => {
1107
+ expect(sanitizeParams({ target: "10.0.0.1", ports: "80" })).toEqual({
1108
+ target: "10.0.0.1",
1109
+ ports: "80",
1110
+ });
1111
+ });
1112
+
1113
+ it("handles null and undefined values", () => {
1114
+ expect(sanitizeParams({ password: null, target: "x" })).toEqual({
1115
+ password: "[REDACTED]",
1116
+ target: "x",
1117
+ });
1118
+ });
1119
+ });
1120
+ ```
1121
+
1122
+ - [ ] **Step 2: Run tests to verify they fail**
1123
+
1124
+ Run: `npx vitest run tests/unit/sanitizer.test.ts`
1125
+ Expected: FAIL
1126
+
1127
+ - [ ] **Step 3: Implement sanitizer.ts**
1128
+
1129
+ Create `src/sanitizer.ts`:
1130
+
1131
+ ```typescript
1132
+ const SENSITIVE_PATTERNS = /^(password|passwd|token|secret|key|credential|auth|apikey|api_key|authorization)$/i;
1133
+
1134
+ export function sanitizeParams(params: Record<string, unknown>): Record<string, unknown> {
1135
+ const result: Record<string, unknown> = {};
1136
+ for (const [key, value] of Object.entries(params)) {
1137
+ if (SENSITIVE_PATTERNS.test(key)) {
1138
+ result[key] = "[REDACTED]";
1139
+ } else if (Array.isArray(value)) {
1140
+ result[key] = value.map((item) =>
1141
+ typeof item === "object" && item !== null
1142
+ ? sanitizeParams(item as Record<string, unknown>)
1143
+ : item
1144
+ );
1145
+ } else if (typeof value === "object" && value !== null) {
1146
+ result[key] = sanitizeParams(value as Record<string, unknown>);
1147
+ } else {
1148
+ result[key] = value;
1149
+ }
1150
+ }
1151
+ return result;
1152
+ }
1153
+ ```
1154
+
1155
+ - [ ] **Step 4: Run tests to verify they pass**
1156
+
1157
+ Run: `npx vitest run tests/unit/sanitizer.test.ts`
1158
+ Expected: All tests pass.
1159
+
1160
+ - [ ] **Step 5: Commit**
1161
+
1162
+ ```bash
1163
+ git add src/sanitizer.ts tests/unit/sanitizer.test.ts
1164
+ git commit -m "feat: add parameter sanitizer to redact secrets from audit logs"
1165
+ ```
1166
+
1167
+ ---
1168
+
1169
+ ## Task 8: DNS Resolver with Caching
1170
+
1171
+ **Files:**
1172
+ - Create: `src/dns-resolver.ts`
1173
+ - Test: `tests/unit/dns-resolver.test.ts`
1174
+
1175
+ - [ ] **Step 1: Write failing tests**
1176
+
1177
+ Create `tests/unit/dns-resolver.test.ts`:
1178
+
1179
+ ```typescript
1180
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1181
+ import { DnsResolver } from "../../src/dns-resolver.js";
1182
+
1183
+ // We mock dns/promises at the module level
1184
+ vi.mock("dns/promises", () => ({
1185
+ default: {
1186
+ resolve4: vi.fn(),
1187
+ resolve6: vi.fn(),
1188
+ },
1189
+ }));
1190
+
1191
+ import dns from "dns/promises";
1192
+
1193
+ const mockResolve4 = vi.mocked(dns.resolve4);
1194
+ const mockResolve6 = vi.mocked(dns.resolve6);
1195
+
1196
+ describe("DnsResolver", () => {
1197
+ let resolver: DnsResolver;
1198
+
1199
+ beforeEach(() => {
1200
+ resolver = new DnsResolver(60);
1201
+ vi.clearAllMocks();
1202
+ });
1203
+
1204
+ it("resolves A records", async () => {
1205
+ mockResolve4.mockResolvedValue(["93.184.216.34"]);
1206
+ mockResolve6.mockRejectedValue(new Error("no AAAA"));
1207
+ const ips = await resolver.resolve("example.com");
1208
+ expect(ips).toEqual(["93.184.216.34"]);
1209
+ });
1210
+
1211
+ it("resolves both A and AAAA records", async () => {
1212
+ mockResolve4.mockResolvedValue(["93.184.216.34"]);
1213
+ mockResolve6.mockResolvedValue(["2606:2800:220:1:248:1893:25c8:1946"]);
1214
+ const ips = await resolver.resolve("example.com");
1215
+ expect(ips).toContain("93.184.216.34");
1216
+ expect(ips).toContain("2606:2800:220:1:248:1893:25c8:1946");
1217
+ });
1218
+
1219
+ it("returns empty array on NXDOMAIN (does not throw)", async () => {
1220
+ mockResolve4.mockRejectedValue(Object.assign(new Error("NXDOMAIN"), { code: "ENOTFOUND" }));
1221
+ mockResolve6.mockRejectedValue(Object.assign(new Error("NXDOMAIN"), { code: "ENOTFOUND" }));
1222
+ const ips = await resolver.resolve("nonexistent.invalid");
1223
+ expect(ips).toEqual([]);
1224
+ });
1225
+
1226
+ it("returns cached result within TTL", async () => {
1227
+ mockResolve4.mockResolvedValue(["1.2.3.4"]);
1228
+ mockResolve6.mockRejectedValue(new Error("no AAAA"));
1229
+
1230
+ await resolver.resolve("cached.example.com");
1231
+ await resolver.resolve("cached.example.com");
1232
+
1233
+ expect(mockResolve4).toHaveBeenCalledTimes(1);
1234
+ });
1235
+
1236
+ it("re-resolves after TTL expires", async () => {
1237
+ const shortTtlResolver = new DnsResolver(0); // 0 second TTL = always expired
1238
+ mockResolve4.mockResolvedValue(["1.2.3.4"]);
1239
+ mockResolve6.mockRejectedValue(new Error("no AAAA"));
1240
+
1241
+ await shortTtlResolver.resolve("ttl.example.com");
1242
+ await shortTtlResolver.resolve("ttl.example.com");
1243
+
1244
+ expect(mockResolve4).toHaveBeenCalledTimes(2);
1245
+ });
1246
+
1247
+ it("recognizes raw IP addresses without DNS lookup", async () => {
1248
+ const ips = await resolver.resolve("10.10.10.5");
1249
+ expect(ips).toEqual(["10.10.10.5"]);
1250
+ expect(mockResolve4).not.toHaveBeenCalled();
1251
+ });
1252
+
1253
+ it("recognizes raw IPv6 addresses without DNS lookup", async () => {
1254
+ const ips = await resolver.resolve("::1");
1255
+ expect(ips).toEqual(["::1"]);
1256
+ expect(mockResolve4).not.toHaveBeenCalled();
1257
+ });
1258
+ });
1259
+ ```
1260
+
1261
+ - [ ] **Step 2: Run tests to verify they fail**
1262
+
1263
+ Run: `npx vitest run tests/unit/dns-resolver.test.ts`
1264
+ Expected: FAIL
1265
+
1266
+ - [ ] **Step 3: Implement dns-resolver.ts**
1267
+
1268
+ Create `src/dns-resolver.ts`:
1269
+
1270
+ ```typescript
1271
+ import dns from "dns/promises";
1272
+ import { isIP } from "net";
1273
+
1274
+ interface CacheEntry {
1275
+ ips: string[];
1276
+ expiresAt: number;
1277
+ }
1278
+
1279
+ export class DnsResolver {
1280
+ private cache = new Map<string, CacheEntry>();
1281
+ private ttlSeconds: number;
1282
+
1283
+ constructor(ttlSeconds: number = 60) {
1284
+ this.ttlSeconds = ttlSeconds;
1285
+ }
1286
+
1287
+ async resolve(target: string): Promise<string[]> {
1288
+ // If it's already an IP address, return it directly
1289
+ if (isIP(target)) {
1290
+ return [target];
1291
+ }
1292
+
1293
+ // Check cache
1294
+ const cached = this.cache.get(target);
1295
+ if (cached && Date.now() < cached.expiresAt) {
1296
+ return cached.ips;
1297
+ }
1298
+
1299
+ // Resolve A and AAAA in parallel
1300
+ const [ipv4, ipv6] = await Promise.all([
1301
+ dns.resolve4(target).catch(() => [] as string[]),
1302
+ dns.resolve6(target).catch(() => [] as string[]),
1303
+ ]);
1304
+
1305
+ const ips = [...ipv4, ...ipv6];
1306
+
1307
+ // Cache the result
1308
+ this.cache.set(target, {
1309
+ ips,
1310
+ expiresAt: Date.now() + this.ttlSeconds * 1000,
1311
+ });
1312
+
1313
+ return ips;
1314
+ }
1315
+ }
1316
+ ```
1317
+
1318
+ - [ ] **Step 4: Run tests to verify they pass**
1319
+
1320
+ Run: `npx vitest run tests/unit/dns-resolver.test.ts`
1321
+ Expected: All tests pass.
1322
+
1323
+ - [ ] **Step 5: Commit**
1324
+
1325
+ ```bash
1326
+ git add src/dns-resolver.ts tests/unit/dns-resolver.test.ts
1327
+ git commit -m "feat: add DNS resolver with TTL-aware caching"
1328
+ ```
1329
+
1330
+ ---
1331
+
1332
+ ## Task 9: Scope Validator Engine
1333
+
1334
+ **Files:**
1335
+ - Create: `src/validator.ts`
1336
+ - Test: `tests/unit/validator.test.ts`
1337
+
1338
+ This is the core orchestrator that ties together all the previous components.
1339
+
1340
+ - [ ] **Step 1: Write failing tests**
1341
+
1342
+ Create `tests/unit/validator.test.ts`:
1343
+
1344
+ ```typescript
1345
+ import { describe, it, expect, vi, beforeEach } from "vitest";
1346
+ import { ScopeValidator } from "../../src/validator.js";
1347
+ import type { ScopeManifest } from "../../src/types.js";
1348
+
1349
+ const baseManifest: ScopeManifest = {
1350
+ engagement_id: "ENG-2025-0142",
1351
+ client: "Acme Corp",
1352
+ operator: "operator@integsec.com",
1353
+ authorized_targets: {
1354
+ ip_ranges: ["10.10.10.0/24", "203.0.113.0/28"],
1355
+ domains: ["*.acme.com", "acme-staging.example.com"],
1356
+ urls: ["https://app.acme.com", "https://api.acme.com"],
1357
+ cloud_accounts: [],
1358
+ },
1359
+ excluded_targets: ["203.0.113.5", "hr.acme.com"],
1360
+ authorized_techniques: ["recon", "web_app", "api_testing"],
1361
+ excluded_techniques: ["dos", "destructive", "social_engineering"],
1362
+ engagement_window: {
1363
+ start: "2020-01-01T00:00:00Z",
1364
+ end: "2099-12-31T23:59:59Z",
1365
+ },
1366
+ };
1367
+
1368
+ describe("ScopeValidator", () => {
1369
+ let validator: ScopeValidator;
1370
+
1371
+ beforeEach(() => {
1372
+ validator = new ScopeValidator(baseManifest);
1373
+ });
1374
+
1375
+ // --- Target authorization ---
1376
+ it("ALLOWS IP in authorized CIDR", async () => {
1377
+ const result = await validator.validate("nmap_scan", { target: "10.10.10.50" });
1378
+ expect(result.decision).toBe("ALLOWED");
1379
+ expect(result.matchedScopeItem).toBe("10.10.10.0/24");
1380
+ });
1381
+
1382
+ it("BLOCKS IP not in any authorized range", async () => {
1383
+ const result = await validator.validate("nmap_scan", { target: "192.168.1.1" });
1384
+ expect(result.decision).toBe("BLOCKED_NOT_IN_SCOPE");
1385
+ });
1386
+
1387
+ it("BLOCKS explicitly excluded IP even if in authorized range", async () => {
1388
+ const result = await validator.validate("nmap_scan", { target: "203.0.113.5" });
1389
+ expect(result.decision).toBe("BLOCKED_EXCLUDED_TARGET");
1390
+ });
1391
+
1392
+ // --- Domain matching ---
1393
+ it("ALLOWS domain matching wildcard", async () => {
1394
+ // Mock DNS to return an in-scope IP
1395
+ validator["dnsResolver"].resolve = vi.fn().mockResolvedValue(["10.10.10.1"]);
1396
+ const result = await validator.validate("curl", { url: "https://app.acme.com/api" });
1397
+ expect(result.decision).toBe("ALLOWED");
1398
+ });
1399
+
1400
+ it("BLOCKS apex domain (wildcard does not match)", async () => {
1401
+ validator["dnsResolver"].resolve = vi.fn().mockResolvedValue(["1.2.3.4"]);
1402
+ const result = await validator.validate("curl", { url: "https://acme.com/" });
1403
+ expect(result.decision).toBe("BLOCKED_NOT_IN_SCOPE");
1404
+ });
1405
+
1406
+ it("BLOCKS two levels deep (wildcard does not match)", async () => {
1407
+ validator["dnsResolver"].resolve = vi.fn().mockResolvedValue(["1.2.3.4"]);
1408
+ const result = await validator.validate("curl", { url: "https://a.b.acme.com/" });
1409
+ expect(result.decision).toBe("BLOCKED_NOT_IN_SCOPE");
1410
+ });
1411
+
1412
+ it("BLOCKS excluded domain even if wildcard matches", async () => {
1413
+ validator["dnsResolver"].resolve = vi.fn().mockResolvedValue(["10.10.10.1"]);
1414
+ const result = await validator.validate("curl", { url: "https://hr.acme.com/" });
1415
+ expect(result.decision).toBe("BLOCKED_EXCLUDED_TARGET");
1416
+ });
1417
+
1418
+ // --- Time window ---
1419
+ it("BLOCKS when outside engagement window", async () => {
1420
+ const expiredManifest = {
1421
+ ...baseManifest,
1422
+ engagement_window: {
1423
+ start: "2020-01-01T00:00:00Z",
1424
+ end: "2020-01-02T00:00:00Z",
1425
+ },
1426
+ };
1427
+ const v = new ScopeValidator(expiredManifest);
1428
+ const result = await v.validate("nmap_scan", { target: "10.10.10.50" });
1429
+ expect(result.decision).toBe("BLOCKED_OUTSIDE_WINDOW");
1430
+ });
1431
+
1432
+ // --- Technique blocking ---
1433
+ it("BLOCKS blocked technique regardless of target", async () => {
1434
+ const result = await validator.validate("slowloris", { target: "10.10.10.50" });
1435
+ expect(result.decision).toBe("BLOCKED_TECHNIQUE");
1436
+ });
1437
+
1438
+ it("BLOCKS tool with dos in name", async () => {
1439
+ const result = await validator.validate("my_dos_tool", { target: "10.10.10.50" });
1440
+ expect(result.decision).toBe("BLOCKED_TECHNIQUE");
1441
+ });
1442
+
1443
+ // --- No target ---
1444
+ it("ALLOWS tool with no target-like parameter", async () => {
1445
+ const result = await validator.validate("list_files", { path: "/tmp" });
1446
+ expect(result.decision).toBe("ALLOWED_NO_TARGET");
1447
+ });
1448
+
1449
+ // --- RFC-1918 and metadata ---
1450
+ it("BLOCKS RFC-1918 address not explicitly in scope", async () => {
1451
+ const noPrivateManifest = {
1452
+ ...baseManifest,
1453
+ authorized_targets: { ...baseManifest.authorized_targets, ip_ranges: ["203.0.113.0/28"] },
1454
+ };
1455
+ const v = new ScopeValidator(noPrivateManifest);
1456
+ const result = await v.validate("nmap_scan", { target: "192.168.1.1" });
1457
+ expect(result.decision).toBe("BLOCKED_NOT_IN_SCOPE");
1458
+ });
1459
+
1460
+ it("BLOCKS cloud metadata IP 169.254.169.254", async () => {
1461
+ const result = await validator.validate("curl", { url: "http://169.254.169.254/latest/meta-data/" });
1462
+ expect(result.decision).toBe("BLOCKED_NOT_IN_SCOPE");
1463
+ });
1464
+
1465
+ // --- DNS failure ---
1466
+ it("BLOCKS when DNS resolution fails (fail closed)", async () => {
1467
+ validator["dnsResolver"].resolve = vi.fn().mockResolvedValue([]);
1468
+ const result = await validator.validate("curl", { url: "https://nonexistent.example.com/" });
1469
+ expect(result.decision).toBe("BLOCKED_DNS_FAILED");
1470
+ });
1471
+ });
1472
+ ```
1473
+
1474
+ - [ ] **Step 2: Run tests to verify they fail**
1475
+
1476
+ Run: `npx vitest run tests/unit/validator.test.ts`
1477
+ Expected: FAIL
1478
+
1479
+ - [ ] **Step 3: Implement validator.ts**
1480
+
1481
+ Create `src/validator.ts`:
1482
+
1483
+ ```typescript
1484
+ import type { ScopeManifest, ValidationResult, Decision } from "./types.js";
1485
+ import { extractTarget } from "./extractor.js";
1486
+ import { DnsResolver } from "./dns-resolver.js";
1487
+ import { isIpInAnyRange, isRfc1918, isCloudMetadata } from "./ip-matcher.js";
1488
+ import { matchesDomain, matchesAnyDomain } from "./domain-matcher.js";
1489
+ import { TechniqueChecker } from "./technique-checker.js";
1490
+
1491
+ export class ScopeValidator {
1492
+ private manifest: ScopeManifest;
1493
+ private dnsResolver: DnsResolver;
1494
+ private techniqueChecker: TechniqueChecker;
1495
+
1496
+ constructor(manifest: ScopeManifest, dnsResolver?: DnsResolver, techniqueChecker?: TechniqueChecker) {
1497
+ this.manifest = manifest;
1498
+ this.dnsResolver = dnsResolver ?? new DnsResolver();
1499
+ this.techniqueChecker = techniqueChecker ?? new TechniqueChecker();
1500
+ }
1501
+
1502
+ async validate(toolName: string, params: Record<string, unknown>): Promise<ValidationResult> {
1503
+ const start = performance.now();
1504
+
1505
+ const result = await this.doValidate(toolName, params);
1506
+
1507
+ result.durationMs = Math.round(performance.now() - start);
1508
+ return result;
1509
+ }
1510
+
1511
+ private async doValidate(toolName: string, params: Record<string, unknown>): Promise<ValidationResult> {
1512
+ // 1. Technique block check
1513
+ if (this.techniqueChecker.isBlocked(toolName, this.manifest.excluded_techniques)) {
1514
+ const category = this.techniqueChecker.getCategory(toolName);
1515
+ return {
1516
+ decision: "BLOCKED_TECHNIQUE",
1517
+ reason: `Tool "${toolName}" matches blocked technique category "${category}"`,
1518
+ durationMs: 0,
1519
+ };
1520
+ }
1521
+
1522
+ // 2. Time window check
1523
+ const now = new Date();
1524
+ const windowStart = new Date(this.manifest.engagement_window.start);
1525
+ const windowEnd = new Date(this.manifest.engagement_window.end);
1526
+ if (now < windowStart || now > windowEnd) {
1527
+ return {
1528
+ decision: "BLOCKED_OUTSIDE_WINDOW",
1529
+ reason: `Current time is outside engagement window (${this.manifest.engagement_window.start} to ${this.manifest.engagement_window.end})`,
1530
+ durationMs: 0,
1531
+ };
1532
+ }
1533
+
1534
+ // 3. Target extraction
1535
+ const extraction = extractTarget(toolName, params);
1536
+ if (!extraction) {
1537
+ return {
1538
+ decision: "ALLOWED_NO_TARGET",
1539
+ reason: "No target-like parameter found in tool call",
1540
+ durationMs: 0,
1541
+ };
1542
+ }
1543
+
1544
+ const { target } = extraction;
1545
+
1546
+ // 4. Resolve DNS if it's a hostname
1547
+ const resolvedIps = await this.dnsResolver.resolve(target);
1548
+
1549
+ // Fail closed on DNS failure (empty result for a hostname)
1550
+ const isIpAddress = /^[\d.]+$/.test(target) || target.includes(":");
1551
+ if (!isIpAddress && resolvedIps.length === 0) {
1552
+ return {
1553
+ decision: "BLOCKED_DNS_FAILED",
1554
+ reason: `DNS resolution failed for "${target}" — blocking (fail closed)`,
1555
+ extractedTarget: target,
1556
+ resolvedIps: [],
1557
+ durationMs: 0,
1558
+ };
1559
+ }
1560
+
1561
+ // 5. Exclusion check — exclusions win
1562
+ // Check hostname against excluded targets
1563
+ if (this.manifest.excluded_targets.some((ex) => matchesDomain(target, ex) || target.toLowerCase() === ex.toLowerCase())) {
1564
+ return {
1565
+ decision: "BLOCKED_EXCLUDED_TARGET",
1566
+ reason: `Target "${target}" is explicitly excluded`,
1567
+ extractedTarget: target,
1568
+ resolvedIps,
1569
+ durationMs: 0,
1570
+ };
1571
+ }
1572
+ // Check resolved IPs against excluded targets
1573
+ for (const ip of resolvedIps) {
1574
+ if (this.manifest.excluded_targets.includes(ip)) {
1575
+ return {
1576
+ decision: "BLOCKED_EXCLUDED_TARGET",
1577
+ reason: `Resolved IP "${ip}" for target "${target}" is explicitly excluded`,
1578
+ extractedTarget: target,
1579
+ resolvedIps,
1580
+ durationMs: 0,
1581
+ };
1582
+ }
1583
+ }
1584
+
1585
+ // 6. Authorization check
1586
+ // Check domain match
1587
+ const domainMatch = matchesAnyDomain(target, this.manifest.authorized_targets.domains);
1588
+
1589
+ // Check IP match
1590
+ let ipMatch: string | null = null;
1591
+ for (const ip of resolvedIps) {
1592
+ // Block RFC-1918 and metadata IPs unless explicitly authorized
1593
+ if ((isRfc1918(ip) || isCloudMetadata(ip)) && !isIpInAnyRange(ip, this.manifest.authorized_targets.ip_ranges)) {
1594
+ continue; // Not authorized, skip — will fall through to NOT_IN_SCOPE
1595
+ }
1596
+ for (const range of this.manifest.authorized_targets.ip_ranges) {
1597
+ const { isIpInCidr } = await import("./ip-matcher.js");
1598
+ if (isIpInCidr(ip, range)) {
1599
+ ipMatch = range;
1600
+ break;
1601
+ }
1602
+ }
1603
+ if (ipMatch) break;
1604
+ }
1605
+
1606
+ if (domainMatch || ipMatch) {
1607
+ return {
1608
+ decision: "ALLOWED",
1609
+ reason: domainMatch
1610
+ ? `Target "${target}" matches authorized domain pattern`
1611
+ : `Target matches authorized range ${ipMatch}`,
1612
+ matchedScopeItem: ipMatch ?? target,
1613
+ extractedTarget: target,
1614
+ resolvedIps,
1615
+ durationMs: 0,
1616
+ };
1617
+ }
1618
+
1619
+ return {
1620
+ decision: "BLOCKED_NOT_IN_SCOPE",
1621
+ reason: `Target "${target}" (resolved: ${resolvedIps.join(", ") || "none"}) is not in authorized scope`,
1622
+ extractedTarget: target,
1623
+ resolvedIps,
1624
+ durationMs: 0,
1625
+ };
1626
+ }
1627
+ }
1628
+ ```
1629
+
1630
+ - [ ] **Step 4: Run tests to verify they pass**
1631
+
1632
+ Run: `npx vitest run tests/unit/validator.test.ts`
1633
+ Expected: All tests pass.
1634
+
1635
+ - [ ] **Step 5: Commit**
1636
+
1637
+ ```bash
1638
+ git add src/validator.ts tests/unit/validator.test.ts
1639
+ git commit -m "feat: add scope validator engine orchestrating all validation checks"
1640
+ ```
1641
+
1642
+ ---
1643
+
1644
+ ## Task 10: Manifest Loader
1645
+
1646
+ **Files:**
1647
+ - Create: `src/manifest.ts`
1648
+ - Create: `src/config.ts`
1649
+ - Test: `tests/unit/manifest.test.ts`
1650
+ - Create: `tests/fixtures/scope-valid.json`
1651
+ - Create: `tests/fixtures/scope-expired.json`
1652
+
1653
+ - [ ] **Step 1: Create test fixtures**
1654
+
1655
+ Create `tests/fixtures/scope-valid.json`:
1656
+ ```json
1657
+ {
1658
+ "engagement_id": "ENG-2025-0142",
1659
+ "client": "Acme Corp",
1660
+ "operator": "operator@integsec.com",
1661
+ "authorized_targets": {
1662
+ "ip_ranges": ["10.10.10.0/24", "203.0.113.0/28"],
1663
+ "domains": ["*.acme.com", "acme-staging.example.com"],
1664
+ "urls": ["https://app.acme.com", "https://api.acme.com"],
1665
+ "cloud_accounts": ["aws:123456789012"]
1666
+ },
1667
+ "excluded_targets": ["203.0.113.5", "hr.acme.com"],
1668
+ "authorized_techniques": ["recon", "web_app", "api_testing"],
1669
+ "excluded_techniques": ["dos", "destructive", "social_engineering"],
1670
+ "engagement_window": {
1671
+ "start": "2020-01-01T00:00:00Z",
1672
+ "end": "2099-12-31T23:59:59Z"
1673
+ }
1674
+ }
1675
+ ```
1676
+
1677
+ Create `tests/fixtures/scope-expired.json`:
1678
+ ```json
1679
+ {
1680
+ "engagement_id": "ENG-OLD-001",
1681
+ "client": "Old Client",
1682
+ "operator": "old@integsec.com",
1683
+ "authorized_targets": {
1684
+ "ip_ranges": ["10.0.0.0/8"],
1685
+ "domains": [],
1686
+ "urls": [],
1687
+ "cloud_accounts": []
1688
+ },
1689
+ "excluded_targets": [],
1690
+ "authorized_techniques": ["recon"],
1691
+ "excluded_techniques": [],
1692
+ "engagement_window": {
1693
+ "start": "2020-01-01T00:00:00Z",
1694
+ "end": "2020-01-02T00:00:00Z"
1695
+ }
1696
+ }
1697
+ ```
1698
+
1699
+ - [ ] **Step 2: Write failing tests for manifest loader**
1700
+
1701
+ Create `tests/unit/manifest.test.ts`:
1702
+
1703
+ ```typescript
1704
+ import { describe, it, expect, vi, beforeEach } from "vitest";
1705
+ import { loadManifest } from "../../src/manifest.js";
1706
+ import { fileURLToPath } from "url";
1707
+ import { dirname, join } from "path";
1708
+
1709
+ const __dirname = dirname(fileURLToPath(import.meta.url));
1710
+ const fixturesDir = join(__dirname, "..", "fixtures");
1711
+
1712
+ describe("loadManifest", () => {
1713
+ beforeEach(() => {
1714
+ // Clear all manifest env vars
1715
+ delete process.env.SCOPE_MANIFEST_PATH;
1716
+ delete process.env.SCOPE_MANIFEST_JSON;
1717
+ delete process.env.SCOPE_MANIFEST_SECRET_ARN;
1718
+ delete process.env.SCOPE_MANIFEST_KEYVAULT_URI;
1719
+ delete process.env.SCOPE_MANIFEST_GCP_SECRET;
1720
+ });
1721
+
1722
+ it("loads manifest from file path", async () => {
1723
+ process.env.SCOPE_MANIFEST_PATH = join(fixturesDir, "scope-valid.json");
1724
+ const manifest = await loadManifest();
1725
+ expect(manifest.engagement_id).toBe("ENG-2025-0142");
1726
+ expect(manifest.client).toBe("Acme Corp");
1727
+ });
1728
+
1729
+ it("loads manifest from inline JSON env var", async () => {
1730
+ process.env.SCOPE_MANIFEST_JSON = JSON.stringify({
1731
+ engagement_id: "ENG-INLINE",
1732
+ client: "Inline",
1733
+ operator: "op@test.com",
1734
+ authorized_targets: { ip_ranges: [], domains: [], urls: [], cloud_accounts: [] },
1735
+ excluded_targets: [],
1736
+ authorized_techniques: [],
1737
+ excluded_techniques: [],
1738
+ engagement_window: { start: "2020-01-01T00:00:00Z", end: "2099-12-31T23:59:59Z" },
1739
+ });
1740
+ const manifest = await loadManifest();
1741
+ expect(manifest.engagement_id).toBe("ENG-INLINE");
1742
+ });
1743
+
1744
+ it("throws if no manifest source is configured", async () => {
1745
+ await expect(loadManifest()).rejects.toThrow(/No scope manifest source configured/);
1746
+ });
1747
+
1748
+ it("throws if manifest file does not exist", async () => {
1749
+ process.env.SCOPE_MANIFEST_PATH = "/nonexistent/path.json";
1750
+ await expect(loadManifest()).rejects.toThrow();
1751
+ });
1752
+
1753
+ it("throws if manifest JSON is invalid", async () => {
1754
+ process.env.SCOPE_MANIFEST_JSON = "{ not valid json";
1755
+ await expect(loadManifest()).rejects.toThrow();
1756
+ });
1757
+
1758
+ it("throws if manifest fails schema validation", async () => {
1759
+ process.env.SCOPE_MANIFEST_JSON = JSON.stringify({ engagement_id: "ENG-1" });
1760
+ await expect(loadManifest()).rejects.toThrow();
1761
+ });
1762
+ });
1763
+ ```
1764
+
1765
+ - [ ] **Step 3: Run tests to verify they fail**
1766
+
1767
+ Run: `npx vitest run tests/unit/manifest.test.ts`
1768
+ Expected: FAIL
1769
+
1770
+ - [ ] **Step 4: Implement config.ts**
1771
+
1772
+ Create `src/config.ts`:
1773
+
1774
+ ```typescript
1775
+ export interface ProxyConfig {
1776
+ // Manifest sources
1777
+ manifestPath?: string;
1778
+ manifestJson?: string;
1779
+ manifestSecretArn?: string;
1780
+ manifestKeyvaultUri?: string;
1781
+ manifestGcpSecret?: string;
1782
+
1783
+ // Transport
1784
+ transport: "stdio" | "sse";
1785
+ upstreamMcpUrl?: string;
1786
+ upstreamMcpCommand?: string;
1787
+ upstreamMcpArgs?: string[];
1788
+ proxyPort: number;
1789
+
1790
+ // Audit
1791
+ auditLogPath: string;
1792
+
1793
+ // Cloud logging
1794
+ awsLogGroup?: string;
1795
+ awsRegion?: string;
1796
+ azureLogWorkspaceId?: string;
1797
+ azureDcrImmutableId?: string;
1798
+ azureDcrStreamName: string;
1799
+ gcpProject?: string;
1800
+
1801
+ // Optional
1802
+ techniqueMapPath?: string;
1803
+ dnsCacheTtl: number;
1804
+ logLevel: string;
1805
+ proxyVersion: string;
1806
+ }
1807
+
1808
+ export function loadConfig(): ProxyConfig {
1809
+ return {
1810
+ manifestPath: process.env.SCOPE_MANIFEST_PATH,
1811
+ manifestJson: process.env.SCOPE_MANIFEST_JSON,
1812
+ manifestSecretArn: process.env.SCOPE_MANIFEST_SECRET_ARN,
1813
+ manifestKeyvaultUri: process.env.SCOPE_MANIFEST_KEYVAULT_URI,
1814
+ manifestGcpSecret: process.env.SCOPE_MANIFEST_GCP_SECRET,
1815
+
1816
+ transport: (process.env.MCP_TRANSPORT as "stdio" | "sse") || "stdio",
1817
+ upstreamMcpUrl: process.env.UPSTREAM_MCP_URL,
1818
+ upstreamMcpCommand: process.env.UPSTREAM_MCP_COMMAND,
1819
+ upstreamMcpArgs: process.env.UPSTREAM_MCP_ARGS?.split(","),
1820
+ proxyPort: parseInt(process.env.PROXY_PORT || "9090", 10),
1821
+
1822
+ auditLogPath: process.env.AUDIT_LOG_PATH || "./audit/",
1823
+
1824
+ awsLogGroup: process.env.AWS_LOG_GROUP,
1825
+ awsRegion: process.env.AWS_REGION,
1826
+ azureLogWorkspaceId: process.env.AZURE_LOG_WORKSPACE_ID,
1827
+ azureDcrImmutableId: process.env.AZURE_DCR_IMMUTABLE_ID,
1828
+ azureDcrStreamName: process.env.AZURE_DCR_STREAM_NAME || "Custom-MCPProxyAudit_CL",
1829
+ gcpProject: process.env.GOOGLE_CLOUD_PROJECT,
1830
+
1831
+ techniqueMapPath: process.env.TECHNIQUE_MAP_PATH,
1832
+ dnsCacheTtl: parseInt(process.env.DNS_CACHE_TTL || "60", 10),
1833
+ logLevel: process.env.LOG_LEVEL || "INFO",
1834
+ proxyVersion: process.env.PROXY_VERSION || "0.1.0",
1835
+ };
1836
+ }
1837
+ ```
1838
+
1839
+ - [ ] **Step 5: Implement manifest.ts**
1840
+
1841
+ Create `src/manifest.ts`:
1842
+
1843
+ ```typescript
1844
+ import { readFile } from "fs/promises";
1845
+ import { ScopeManifestSchema, type ScopeManifest } from "./manifest-schema.js";
1846
+
1847
+ export async function loadManifest(): Promise<ScopeManifest> {
1848
+ const raw = await loadRawJson();
1849
+ const parsed = ScopeManifestSchema.safeParse(raw);
1850
+ if (!parsed.success) {
1851
+ throw new Error(`Scope manifest validation failed: ${parsed.error.message}`);
1852
+ }
1853
+ return parsed.data;
1854
+ }
1855
+
1856
+ async function loadRawJson(): Promise<unknown> {
1857
+ // 1. Inline JSON
1858
+ if (process.env.SCOPE_MANIFEST_JSON) {
1859
+ return JSON.parse(process.env.SCOPE_MANIFEST_JSON);
1860
+ }
1861
+
1862
+ // 2. Local file
1863
+ if (process.env.SCOPE_MANIFEST_PATH) {
1864
+ const content = await readFile(process.env.SCOPE_MANIFEST_PATH, "utf-8");
1865
+ return JSON.parse(content);
1866
+ }
1867
+
1868
+ // 3. AWS Secrets Manager
1869
+ if (process.env.SCOPE_MANIFEST_SECRET_ARN) {
1870
+ return loadFromAwsSecrets(process.env.SCOPE_MANIFEST_SECRET_ARN);
1871
+ }
1872
+
1873
+ // 4. Azure Key Vault
1874
+ if (process.env.SCOPE_MANIFEST_KEYVAULT_URI) {
1875
+ return loadFromAzureKeyVault(process.env.SCOPE_MANIFEST_KEYVAULT_URI);
1876
+ }
1877
+
1878
+ // 5. GCP Secret Manager
1879
+ if (process.env.SCOPE_MANIFEST_GCP_SECRET) {
1880
+ return loadFromGcpSecrets(process.env.SCOPE_MANIFEST_GCP_SECRET);
1881
+ }
1882
+
1883
+ throw new Error(
1884
+ "No scope manifest source configured. Set one of: SCOPE_MANIFEST_PATH, SCOPE_MANIFEST_JSON, SCOPE_MANIFEST_SECRET_ARN, SCOPE_MANIFEST_KEYVAULT_URI, SCOPE_MANIFEST_GCP_SECRET"
1885
+ );
1886
+ }
1887
+
1888
+ async function loadFromAwsSecrets(secretArn: string): Promise<unknown> {
1889
+ const { SecretsManagerClient, GetSecretValueCommand } = await import("@aws-sdk/client-secrets-manager");
1890
+ const client = new SecretsManagerClient({});
1891
+ const resp = await client.send(new GetSecretValueCommand({ SecretId: secretArn }));
1892
+ if (!resp.SecretString) throw new Error("AWS secret has no string value");
1893
+ return JSON.parse(resp.SecretString);
1894
+ }
1895
+
1896
+ async function loadFromAzureKeyVault(secretUri: string): Promise<unknown> {
1897
+ const { SecretClient } = await import("@azure/keyvault-secrets");
1898
+ const { DefaultAzureCredential } = await import("@azure/identity");
1899
+ // Extract vault URL and secret name from URI
1900
+ const url = new URL(secretUri);
1901
+ const vaultUrl = `${url.protocol}//${url.host}`;
1902
+ const secretName = url.pathname.split("/")[2];
1903
+ const client = new SecretClient(vaultUrl, new DefaultAzureCredential());
1904
+ const secret = await client.getSecret(secretName);
1905
+ if (!secret.value) throw new Error("Azure Key Vault secret has no value");
1906
+ return JSON.parse(secret.value);
1907
+ }
1908
+
1909
+ async function loadFromGcpSecrets(resourceName: string): Promise<unknown> {
1910
+ const { SecretManagerServiceClient } = await import("@google-cloud/secret-manager");
1911
+ const client = new SecretManagerServiceClient();
1912
+ const [version] = await client.accessSecretVersion({ name: resourceName });
1913
+ const payload = version.payload?.data;
1914
+ if (!payload) throw new Error("GCP secret has no payload");
1915
+ const str = typeof payload === "string" ? payload : Buffer.from(payload).toString("utf-8");
1916
+ return JSON.parse(str);
1917
+ }
1918
+ ```
1919
+
1920
+ - [ ] **Step 6: Run tests to verify they pass**
1921
+
1922
+ Run: `npx vitest run tests/unit/manifest.test.ts`
1923
+ Expected: All tests pass.
1924
+
1925
+ - [ ] **Step 7: Commit**
1926
+
1927
+ ```bash
1928
+ git add src/config.ts src/manifest.ts tests/unit/manifest.test.ts tests/fixtures/
1929
+ git commit -m "feat: add manifest loader with file, env, and cloud secret sources"
1930
+ ```
1931
+
1932
+ ---
1933
+
1934
+ ## Task 11: Local Audit Logger
1935
+
1936
+ **Files:**
1937
+ - Create: `src/audit/local.ts`
1938
+ - Create: `src/audit/index.ts`
1939
+ - Test: `tests/unit/audit-local.test.ts`
1940
+
1941
+ - [ ] **Step 1: Write failing tests**
1942
+
1943
+ Create `tests/unit/audit-local.test.ts`:
1944
+
1945
+ ```typescript
1946
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1947
+ import { LocalAuditLogger } from "../../src/audit/local.js";
1948
+ import { readFile, rm, mkdir } from "fs/promises";
1949
+ import { join } from "path";
1950
+ import { tmpdir } from "os";
1951
+ import type { AuditEntry } from "../../src/types.js";
1952
+
1953
+ const testDir = join(tmpdir(), "mcp-proxy-test-audit-" + Date.now());
1954
+
1955
+ const sampleEntry: AuditEntry = {
1956
+ timestamp: "2026-03-26T14:32:01.234Z",
1957
+ engagement_id: "ENG-TEST-001",
1958
+ client: "Test Corp",
1959
+ operator: "test@integsec.com",
1960
+ tool_name: "nmap_scan",
1961
+ tool_parameters: { target: "10.10.10.5", ports: "1-1024" },
1962
+ extracted_target: "10.10.10.5",
1963
+ resolved_ips: ["10.10.10.5"],
1964
+ decision: "ALLOWED",
1965
+ decision_reason: "Target matches authorized range 10.10.10.0/24",
1966
+ matched_scope_item: "10.10.10.0/24",
1967
+ duration_ms: 5,
1968
+ proxy_version: "0.1.0",
1969
+ };
1970
+
1971
+ describe("LocalAuditLogger", () => {
1972
+ let logger: LocalAuditLogger;
1973
+
1974
+ beforeEach(async () => {
1975
+ await mkdir(testDir, { recursive: true });
1976
+ logger = new LocalAuditLogger(testDir);
1977
+ });
1978
+
1979
+ afterEach(async () => {
1980
+ await rm(testDir, { recursive: true, force: true });
1981
+ });
1982
+
1983
+ it("writes a JSONL entry to file", async () => {
1984
+ await logger.log(sampleEntry);
1985
+ await logger.flush();
1986
+ const filePath = join(testDir, "ENG-TEST-001.jsonl");
1987
+ const content = await readFile(filePath, "utf-8");
1988
+ const lines = content.trim().split("\n");
1989
+ expect(lines).toHaveLength(1);
1990
+ const parsed = JSON.parse(lines[0]);
1991
+ expect(parsed.engagement_id).toBe("ENG-TEST-001");
1992
+ expect(parsed.decision).toBe("ALLOWED");
1993
+ });
1994
+
1995
+ it("appends multiple entries", async () => {
1996
+ await logger.log(sampleEntry);
1997
+ await logger.log({ ...sampleEntry, decision: "BLOCKED_NOT_IN_SCOPE" as const });
1998
+ await logger.flush();
1999
+ const filePath = join(testDir, "ENG-TEST-001.jsonl");
2000
+ const content = await readFile(filePath, "utf-8");
2001
+ const lines = content.trim().split("\n");
2002
+ expect(lines).toHaveLength(2);
2003
+ });
2004
+ });
2005
+ ```
2006
+
2007
+ - [ ] **Step 2: Run tests to verify they fail**
2008
+
2009
+ Run: `npx vitest run tests/unit/audit-local.test.ts`
2010
+ Expected: FAIL
2011
+
2012
+ - [ ] **Step 3: Implement local.ts**
2013
+
2014
+ Create `src/audit/local.ts`:
2015
+
2016
+ ```typescript
2017
+ import { appendFile, mkdir } from "fs/promises";
2018
+ import { join } from "path";
2019
+ import type { AuditEntry } from "../types.js";
2020
+
2021
+ export class LocalAuditLogger {
2022
+ private basePath: string;
2023
+ private buffer: Map<string, string[]> = new Map();
2024
+
2025
+ constructor(basePath: string) {
2026
+ this.basePath = basePath;
2027
+ }
2028
+
2029
+ async log(entry: AuditEntry): Promise<void> {
2030
+ const line = JSON.stringify(entry) + "\n";
2031
+ const existing = this.buffer.get(entry.engagement_id) ?? [];
2032
+ existing.push(line);
2033
+ this.buffer.set(entry.engagement_id, existing);
2034
+ }
2035
+
2036
+ async flush(): Promise<void> {
2037
+ await mkdir(this.basePath, { recursive: true });
2038
+ for (const [engagementId, lines] of this.buffer) {
2039
+ const filePath = join(this.basePath, `${engagementId}.jsonl`);
2040
+ await appendFile(filePath, lines.join(""));
2041
+ }
2042
+ this.buffer.clear();
2043
+ }
2044
+ }
2045
+ ```
2046
+
2047
+ - [ ] **Step 4: Implement audit/index.ts (facade)**
2048
+
2049
+ Create `src/audit/index.ts`:
2050
+
2051
+ ```typescript
2052
+ import type { AuditEntry } from "../types.js";
2053
+ import { LocalAuditLogger } from "./local.js";
2054
+
2055
+ export interface AuditBackend {
2056
+ log(entry: AuditEntry): Promise<void>;
2057
+ flush(): Promise<void>;
2058
+ }
2059
+
2060
+ export class AuditLogger {
2061
+ private backends: AuditBackend[] = [];
2062
+ private flushIntervalMs: number;
2063
+ private flushTimer?: ReturnType<typeof setInterval>;
2064
+
2065
+ constructor(localPath: string, flushIntervalMs: number = 5000) {
2066
+ this.backends.push(new LocalAuditLogger(localPath));
2067
+ this.flushIntervalMs = flushIntervalMs;
2068
+ }
2069
+
2070
+ addBackend(backend: AuditBackend): void {
2071
+ this.backends.push(backend);
2072
+ }
2073
+
2074
+ start(): void {
2075
+ this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
2076
+ }
2077
+
2078
+ async log(entry: AuditEntry): Promise<void> {
2079
+ const promises = this.backends.map((b) =>
2080
+ b.log(entry).catch((err) => {
2081
+ console.error(`[mcp-scope-proxy] Audit backend error: ${err.message}`);
2082
+ })
2083
+ );
2084
+ await Promise.all(promises);
2085
+ }
2086
+
2087
+ async flush(): Promise<void> {
2088
+ const promises = this.backends.map((b) =>
2089
+ b.flush().catch((err) => {
2090
+ console.error(`[mcp-scope-proxy] Audit flush error: ${err.message}`);
2091
+ })
2092
+ );
2093
+ await Promise.all(promises);
2094
+ }
2095
+
2096
+ async stop(): Promise<void> {
2097
+ if (this.flushTimer) clearInterval(this.flushTimer);
2098
+ await this.flush();
2099
+ }
2100
+ }
2101
+ ```
2102
+
2103
+ - [ ] **Step 5: Run tests to verify they pass**
2104
+
2105
+ Run: `npx vitest run tests/unit/audit-local.test.ts`
2106
+ Expected: All tests pass.
2107
+
2108
+ - [ ] **Step 6: Commit**
2109
+
2110
+ ```bash
2111
+ git add src/audit/ tests/unit/audit-local.test.ts
2112
+ git commit -m "feat: add local JSONL audit logger with flush support"
2113
+ ```
2114
+
2115
+ ---
2116
+
2117
+ ## Task 12: Cloud Audit Backends (AWS, Azure, GCP)
2118
+
2119
+ **Files:**
2120
+ - Create: `src/audit/cloudwatch.ts`
2121
+ - Create: `src/audit/azure-monitor.ts`
2122
+ - Create: `src/audit/gcp-logging.ts`
2123
+
2124
+ These use optional dependencies and dynamic imports, so they gracefully skip if the cloud SDK isn't installed.
2125
+
2126
+ - [ ] **Step 1: Implement cloudwatch.ts**
2127
+
2128
+ Create `src/audit/cloudwatch.ts`:
2129
+
2130
+ ```typescript
2131
+ import type { AuditEntry } from "../types.js";
2132
+ import type { AuditBackend } from "./index.js";
2133
+
2134
+ export class CloudWatchAuditLogger implements AuditBackend {
2135
+ private logGroup: string;
2136
+ private buffer: AuditEntry[] = [];
2137
+ private client: any;
2138
+ private sequenceToken?: string;
2139
+
2140
+ constructor(logGroup: string, region?: string) {
2141
+ this.logGroup = logGroup;
2142
+ this.initClient(region);
2143
+ }
2144
+
2145
+ private async initClient(region?: string): Promise<void> {
2146
+ try {
2147
+ const { CloudWatchLogsClient } = await import("@aws-sdk/client-cloudwatch-logs");
2148
+ this.client = new CloudWatchLogsClient(region ? { region } : {});
2149
+ } catch {
2150
+ console.warn("[mcp-scope-proxy] @aws-sdk/client-cloudwatch-logs not installed, CloudWatch logging disabled");
2151
+ }
2152
+ }
2153
+
2154
+ async log(entry: AuditEntry): Promise<void> {
2155
+ this.buffer.push(entry);
2156
+ }
2157
+
2158
+ async flush(): Promise<void> {
2159
+ if (!this.client || this.buffer.length === 0) return;
2160
+ const entries = this.buffer.splice(0, this.buffer.length);
2161
+
2162
+ try {
2163
+ const { PutLogEventsCommand, CreateLogGroupCommand, CreateLogStreamCommand } = await import(
2164
+ "@aws-sdk/client-cloudwatch-logs"
2165
+ );
2166
+ const logStream = `${entries[0].engagement_id}/${new Date().toISOString().split("T")[0]}`;
2167
+
2168
+ // Ensure log group and stream exist (idempotent)
2169
+ try {
2170
+ await this.client.send(new CreateLogGroupCommand({ logGroupName: this.logGroup }));
2171
+ } catch (e: any) {
2172
+ if (e.name !== "ResourceAlreadyExistsException") throw e;
2173
+ }
2174
+ try {
2175
+ await this.client.send(
2176
+ new CreateLogStreamCommand({ logGroupName: this.logGroup, logStreamName: logStream })
2177
+ );
2178
+ } catch (e: any) {
2179
+ if (e.name !== "ResourceAlreadyExistsException") throw e;
2180
+ }
2181
+
2182
+ await this.client.send(
2183
+ new PutLogEventsCommand({
2184
+ logGroupName: this.logGroup,
2185
+ logStreamName: logStream,
2186
+ logEvents: entries.map((e) => ({
2187
+ timestamp: new Date(e.timestamp).getTime(),
2188
+ message: JSON.stringify(e),
2189
+ })),
2190
+ })
2191
+ );
2192
+ } catch (err: any) {
2193
+ console.error(`[mcp-scope-proxy] CloudWatch flush error: ${err.message}`);
2194
+ // Put entries back for retry
2195
+ this.buffer.unshift(...entries);
2196
+ }
2197
+ }
2198
+ }
2199
+ ```
2200
+
2201
+ - [ ] **Step 2: Implement azure-monitor.ts**
2202
+
2203
+ Create `src/audit/azure-monitor.ts`:
2204
+
2205
+ ```typescript
2206
+ import type { AuditEntry } from "../types.js";
2207
+ import type { AuditBackend } from "./index.js";
2208
+
2209
+ export class AzureMonitorAuditLogger implements AuditBackend {
2210
+ private buffer: AuditEntry[] = [];
2211
+ private client: any;
2212
+ private ruleId: string;
2213
+ private streamName: string;
2214
+
2215
+ constructor(workspaceId: string, dcrImmutableId: string, streamName: string) {
2216
+ this.ruleId = dcrImmutableId;
2217
+ this.streamName = streamName;
2218
+ this.initClient(workspaceId);
2219
+ }
2220
+
2221
+ private async initClient(workspaceId: string): Promise<void> {
2222
+ try {
2223
+ const { LogsIngestionClient } = await import("@azure/monitor-ingestion");
2224
+ const { DefaultAzureCredential } = await import("@azure/identity");
2225
+ const endpoint = `https://${workspaceId}.ingest.monitor.azure.com`;
2226
+ this.client = new LogsIngestionClient(endpoint, new DefaultAzureCredential());
2227
+ } catch {
2228
+ console.warn("[mcp-scope-proxy] @azure/monitor-ingestion not installed, Azure Monitor logging disabled");
2229
+ }
2230
+ }
2231
+
2232
+ async log(entry: AuditEntry): Promise<void> {
2233
+ this.buffer.push(entry);
2234
+ }
2235
+
2236
+ async flush(): Promise<void> {
2237
+ if (!this.client || this.buffer.length === 0) return;
2238
+ const entries = this.buffer.splice(0, this.buffer.length);
2239
+
2240
+ try {
2241
+ await this.client.upload(this.ruleId, this.streamName, entries);
2242
+ } catch (err: any) {
2243
+ console.error(`[mcp-scope-proxy] Azure Monitor flush error: ${err.message}`);
2244
+ this.buffer.unshift(...entries);
2245
+ }
2246
+ }
2247
+ }
2248
+ ```
2249
+
2250
+ - [ ] **Step 3: Implement gcp-logging.ts**
2251
+
2252
+ Create `src/audit/gcp-logging.ts`:
2253
+
2254
+ ```typescript
2255
+ import type { AuditEntry } from "../types.js";
2256
+ import type { AuditBackend } from "./index.js";
2257
+
2258
+ export class GcpAuditLogger implements AuditBackend {
2259
+ private buffer: AuditEntry[] = [];
2260
+ private log: any;
2261
+
2262
+ constructor(projectId?: string) {
2263
+ this.initClient(projectId);
2264
+ }
2265
+
2266
+ private async initClient(projectId?: string): Promise<void> {
2267
+ try {
2268
+ const { Logging } = await import("@google-cloud/logging");
2269
+ const logging = new Logging(projectId ? { projectId } : {});
2270
+ this.log = logging.log("mcp-proxy-audit");
2271
+ } catch {
2272
+ console.warn("[mcp-scope-proxy] @google-cloud/logging not installed, GCP logging disabled");
2273
+ }
2274
+ }
2275
+
2276
+ async log(entry: AuditEntry): Promise<void> {
2277
+ this.buffer.push(entry);
2278
+ }
2279
+
2280
+ async flush(): Promise<void> {
2281
+ if (!this.log || this.buffer.length === 0) return;
2282
+ const entries = this.buffer.splice(0, this.buffer.length);
2283
+
2284
+ try {
2285
+ const logEntries = entries.map((e) => {
2286
+ const metadata = {
2287
+ resource: { type: "global" },
2288
+ labels: { engagement_id: e.engagement_id, client: e.client },
2289
+ severity: e.decision.startsWith("BLOCKED") ? "WARNING" : "INFO",
2290
+ };
2291
+ return this.log.entry(metadata, e);
2292
+ });
2293
+ await this.log.write(logEntries);
2294
+ } catch (err: any) {
2295
+ console.error(`[mcp-scope-proxy] GCP Logging flush error: ${err.message}`);
2296
+ this.buffer.unshift(...entries);
2297
+ }
2298
+ }
2299
+ }
2300
+ ```
2301
+
2302
+ - [ ] **Step 4: Commit**
2303
+
2304
+ ```bash
2305
+ git add src/audit/cloudwatch.ts src/audit/azure-monitor.ts src/audit/gcp-logging.ts
2306
+ git commit -m "feat: add cloud audit backends for CloudWatch, Azure Monitor, and GCP Logging"
2307
+ ```
2308
+
2309
+ ---
2310
+
2311
+ ## Task 13: Core MCP Proxy
2312
+
2313
+ **Files:**
2314
+ - Create: `src/proxy.ts`
2315
+ - Update: `src/index.ts`
2316
+
2317
+ - [ ] **Step 1: Implement proxy.ts**
2318
+
2319
+ Create `src/proxy.ts`:
2320
+
2321
+ ```typescript
2322
+ import type { ScopeManifest, AuditEntry, Decision } from "./types.js";
2323
+ import { ScopeValidator } from "./validator.js";
2324
+ import { AuditLogger } from "./audit/index.js";
2325
+ import { sanitizeParams } from "./sanitizer.js";
2326
+ import { DnsResolver } from "./dns-resolver.js";
2327
+ import { TechniqueChecker } from "./technique-checker.js";
2328
+ import type { ProxyConfig } from "./config.js";
2329
+
2330
+ export class ScopeEnforcementProxy {
2331
+ private validator: ScopeValidator;
2332
+ private auditLogger: AuditLogger;
2333
+ private manifest: ScopeManifest;
2334
+ private config: ProxyConfig;
2335
+
2336
+ constructor(manifest: ScopeManifest, config: ProxyConfig) {
2337
+ this.manifest = manifest;
2338
+ this.config = config;
2339
+
2340
+ const dnsResolver = new DnsResolver(config.dnsCacheTtl);
2341
+ const techniqueChecker = new TechniqueChecker(); // TODO: load custom mappings from config.techniqueMapPath
2342
+ this.validator = new ScopeValidator(manifest, dnsResolver, techniqueChecker);
2343
+ this.auditLogger = new AuditLogger(config.auditLogPath);
2344
+ }
2345
+
2346
+ getAuditLogger(): AuditLogger {
2347
+ return this.auditLogger;
2348
+ }
2349
+
2350
+ /**
2351
+ * Handle an incoming JSON-RPC message. Returns the message to forward (possibly modified),
2352
+ * or an error response if blocked.
2353
+ */
2354
+ async handleMessage(message: unknown): Promise<{ forward: boolean; response?: unknown }> {
2355
+ // Only intercept tools/call requests
2356
+ if (!isToolCallRequest(message)) {
2357
+ return { forward: true };
2358
+ }
2359
+
2360
+ const { id, params } = message as any;
2361
+ const toolName: string = params?.name ?? "unknown";
2362
+ const toolParams: Record<string, unknown> = params?.arguments ?? {};
2363
+
2364
+ const result = await this.validator.validate(toolName, toolParams);
2365
+
2366
+ // Build audit entry
2367
+ const entry: AuditEntry = {
2368
+ timestamp: new Date().toISOString(),
2369
+ engagement_id: this.manifest.engagement_id,
2370
+ client: this.manifest.client,
2371
+ operator: this.manifest.operator,
2372
+ tool_name: toolName,
2373
+ tool_parameters: sanitizeParams(toolParams),
2374
+ extracted_target: result.extractedTarget ?? null,
2375
+ resolved_ips: result.resolvedIps ?? [],
2376
+ decision: result.decision,
2377
+ decision_reason: result.reason,
2378
+ matched_scope_item: result.matchedScopeItem ?? null,
2379
+ duration_ms: result.durationMs,
2380
+ proxy_version: this.config.proxyVersion,
2381
+ };
2382
+
2383
+ // Log asynchronously — never block on logging failure
2384
+ this.auditLogger.log(entry).catch((err) => {
2385
+ console.error(`[mcp-scope-proxy] Failed to log audit entry: ${err.message}`);
2386
+ });
2387
+
2388
+ // If blocked, return error response
2389
+ if (result.decision.startsWith("BLOCKED")) {
2390
+ return {
2391
+ forward: false,
2392
+ response: {
2393
+ jsonrpc: "2.0",
2394
+ id,
2395
+ error: {
2396
+ code: -32001,
2397
+ message: `SCOPE_VIOLATION: ${result.reason}. Decision: ${result.decision}.`,
2398
+ },
2399
+ },
2400
+ };
2401
+ }
2402
+
2403
+ // Allowed — forward to upstream
2404
+ return { forward: true };
2405
+ }
2406
+ }
2407
+
2408
+ function isToolCallRequest(msg: unknown): boolean {
2409
+ if (typeof msg !== "object" || msg === null) return false;
2410
+ const obj = msg as Record<string, unknown>;
2411
+ return obj.method === "tools/call" && obj.jsonrpc === "2.0";
2412
+ }
2413
+ ```
2414
+
2415
+ - [ ] **Step 2: Update src/index.ts**
2416
+
2417
+ ```typescript
2418
+ export { ScopeEnforcementProxy } from "./proxy.js";
2419
+ export { ScopeValidator } from "./validator.js";
2420
+ export { loadManifest } from "./manifest.js";
2421
+ export { loadConfig } from "./config.js";
2422
+ export { AuditLogger } from "./audit/index.js";
2423
+ export type { ScopeManifest, AuditEntry, ValidationResult, Decision } from "./types.js";
2424
+ export const VERSION = "0.1.0";
2425
+ ```
2426
+
2427
+ - [ ] **Step 3: Commit**
2428
+
2429
+ ```bash
2430
+ git add src/proxy.ts src/index.ts
2431
+ git commit -m "feat: add core MCP scope enforcement proxy with message interception"
2432
+ ```
2433
+
2434
+ ---
2435
+
2436
+ ## Task 14: stdio Transport
2437
+
2438
+ **Files:**
2439
+ - Create: `src/transports/stdio.ts`
2440
+
2441
+ - [ ] **Step 1: Implement stdio transport**
2442
+
2443
+ Create `src/transports/stdio.ts`:
2444
+
2445
+ ```typescript
2446
+ import { spawn, type ChildProcess } from "child_process";
2447
+ import { createInterface } from "readline";
2448
+ import type { ScopeEnforcementProxy } from "../proxy.js";
2449
+
2450
+ export async function runStdioProxy(
2451
+ proxy: ScopeEnforcementProxy,
2452
+ upstreamCommand: string,
2453
+ upstreamArgs: string[] = []
2454
+ ): Promise<void> {
2455
+ // Spawn the upstream MCP server
2456
+ const upstream: ChildProcess = spawn(upstreamCommand, upstreamArgs, {
2457
+ stdio: ["pipe", "pipe", "inherit"],
2458
+ });
2459
+
2460
+ if (!upstream.stdin || !upstream.stdout) {
2461
+ throw new Error("Failed to open stdio pipes to upstream MCP server");
2462
+ }
2463
+
2464
+ const auditLogger = proxy.getAuditLogger();
2465
+ auditLogger.start();
2466
+
2467
+ // Read lines from agent (our stdin) and process
2468
+ const agentInput = createInterface({ input: process.stdin, crlfDelay: Infinity });
2469
+ const upstreamOutput = createInterface({ input: upstream.stdout, crlfDelay: Infinity });
2470
+
2471
+ // Agent → Proxy → Upstream
2472
+ agentInput.on("line", async (line) => {
2473
+ try {
2474
+ const message = JSON.parse(line);
2475
+ const { forward, response } = await proxy.handleMessage(message);
2476
+ if (forward) {
2477
+ upstream.stdin!.write(line + "\n");
2478
+ } else if (response) {
2479
+ process.stdout.write(JSON.stringify(response) + "\n");
2480
+ }
2481
+ } catch {
2482
+ // Malformed JSON — pass through conservatively
2483
+ upstream.stdin!.write(line + "\n");
2484
+ }
2485
+ });
2486
+
2487
+ // Upstream → Agent (pass through all responses)
2488
+ upstreamOutput.on("line", (line) => {
2489
+ process.stdout.write(line + "\n");
2490
+ });
2491
+
2492
+ // Handle upstream exit
2493
+ upstream.on("exit", async (code) => {
2494
+ await auditLogger.stop();
2495
+ process.exit(code ?? 0);
2496
+ });
2497
+
2498
+ // Handle our own termination
2499
+ process.on("SIGINT", async () => {
2500
+ await auditLogger.stop();
2501
+ upstream.kill();
2502
+ process.exit(0);
2503
+ });
2504
+
2505
+ process.on("SIGTERM", async () => {
2506
+ await auditLogger.stop();
2507
+ upstream.kill();
2508
+ process.exit(0);
2509
+ });
2510
+ }
2511
+ ```
2512
+
2513
+ - [ ] **Step 2: Commit**
2514
+
2515
+ ```bash
2516
+ git add src/transports/stdio.ts
2517
+ git commit -m "feat: add stdio transport for proxying MCP over stdin/stdout"
2518
+ ```
2519
+
2520
+ ---
2521
+
2522
+ ## Task 15: HTTP (Streamable HTTP) Transport
2523
+
2524
+ **Files:**
2525
+ - Create: `src/transports/http.ts`
2526
+
2527
+ - [ ] **Step 1: Implement HTTP transport**
2528
+
2529
+ Create `src/transports/http.ts`:
2530
+
2531
+ ```typescript
2532
+ import { createServer, type IncomingMessage, type ServerResponse } from "http";
2533
+ import type { ScopeEnforcementProxy } from "../proxy.js";
2534
+
2535
+ export async function runHttpProxy(
2536
+ proxy: ScopeEnforcementProxy,
2537
+ upstreamUrl: string,
2538
+ port: number
2539
+ ): Promise<void> {
2540
+ const auditLogger = proxy.getAuditLogger();
2541
+ auditLogger.start();
2542
+
2543
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
2544
+ const body = await readBody(req);
2545
+
2546
+ // Try to parse as JSON-RPC
2547
+ let message: unknown;
2548
+ try {
2549
+ message = JSON.parse(body);
2550
+ } catch {
2551
+ // Not JSON — forward as-is
2552
+ return forwardRaw(upstreamUrl, req, body, res);
2553
+ }
2554
+
2555
+ // Check if this is a tools/call we need to intercept
2556
+ const { forward, response } = await proxy.handleMessage(message);
2557
+
2558
+ if (!forward && response) {
2559
+ res.writeHead(200, { "Content-Type": "application/json" });
2560
+ res.end(JSON.stringify(response));
2561
+ return;
2562
+ }
2563
+
2564
+ // Forward to upstream
2565
+ return forwardRaw(upstreamUrl, req, body, res);
2566
+ });
2567
+
2568
+ server.listen(port, () => {
2569
+ console.error(`[mcp-scope-proxy] HTTP proxy listening on port ${port}, forwarding to ${upstreamUrl}`);
2570
+ });
2571
+
2572
+ process.on("SIGINT", async () => {
2573
+ await auditLogger.stop();
2574
+ server.close();
2575
+ process.exit(0);
2576
+ });
2577
+
2578
+ process.on("SIGTERM", async () => {
2579
+ await auditLogger.stop();
2580
+ server.close();
2581
+ process.exit(0);
2582
+ });
2583
+ }
2584
+
2585
+ function readBody(req: IncomingMessage): Promise<string> {
2586
+ return new Promise((resolve, reject) => {
2587
+ const chunks: Buffer[] = [];
2588
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
2589
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
2590
+ req.on("error", reject);
2591
+ });
2592
+ }
2593
+
2594
+ async function forwardRaw(
2595
+ upstreamUrl: string,
2596
+ originalReq: IncomingMessage,
2597
+ body: string,
2598
+ clientRes: ServerResponse
2599
+ ): Promise<void> {
2600
+ try {
2601
+ const url = new URL(originalReq.url ?? "/", upstreamUrl);
2602
+ const headers: Record<string, string> = {};
2603
+ for (const [key, value] of Object.entries(originalReq.headers)) {
2604
+ if (typeof value === "string") headers[key] = value;
2605
+ }
2606
+ // Remove host header to avoid conflicts
2607
+ delete headers.host;
2608
+
2609
+ const resp = await fetch(url.toString(), {
2610
+ method: originalReq.method ?? "POST",
2611
+ headers,
2612
+ body: originalReq.method !== "GET" ? body : undefined,
2613
+ });
2614
+
2615
+ clientRes.writeHead(resp.status, Object.fromEntries(resp.headers.entries()));
2616
+ const respBody = await resp.text();
2617
+ clientRes.end(respBody);
2618
+ } catch (err: any) {
2619
+ clientRes.writeHead(502, { "Content-Type": "application/json" });
2620
+ clientRes.end(JSON.stringify({ error: `Upstream error: ${err.message}` }));
2621
+ }
2622
+ }
2623
+ ```
2624
+
2625
+ - [ ] **Step 2: Commit**
2626
+
2627
+ ```bash
2628
+ git add src/transports/http.ts
2629
+ git commit -m "feat: add HTTP transport for proxying MCP over Streamable HTTP"
2630
+ ```
2631
+
2632
+ ---
2633
+
2634
+ ## Task 16: CLI Entry Point
2635
+
2636
+ **Files:**
2637
+ - Create: `bin/mcp-scope-proxy.ts`
2638
+
2639
+ - [ ] **Step 1: Implement CLI entry point**
2640
+
2641
+ Create `bin/mcp-scope-proxy.ts`:
2642
+
2643
+ ```typescript
2644
+ #!/usr/bin/env node
2645
+
2646
+ import { loadConfig } from "../src/config.js";
2647
+ import { loadManifest } from "../src/manifest.js";
2648
+ import { ScopeEnforcementProxy } from "../src/proxy.js";
2649
+ import { runStdioProxy } from "../src/transports/stdio.js";
2650
+ import { runHttpProxy } from "../src/transports/http.js";
2651
+ import { CloudWatchAuditLogger } from "../src/audit/cloudwatch.js";
2652
+ import { AzureMonitorAuditLogger } from "../src/audit/azure-monitor.js";
2653
+ import { GcpAuditLogger } from "../src/audit/gcp-logging.js";
2654
+
2655
+ async function main(): Promise<void> {
2656
+ console.error("[mcp-scope-proxy] Starting...");
2657
+
2658
+ const config = loadConfig();
2659
+ const manifest = await loadManifest();
2660
+
2661
+ console.error(`[mcp-scope-proxy] Loaded scope for engagement ${manifest.engagement_id} (${manifest.client})`);
2662
+ console.error(`[mcp-scope-proxy] Window: ${manifest.engagement_window.start} to ${manifest.engagement_window.end}`);
2663
+ console.error(`[mcp-scope-proxy] Transport: ${config.transport}`);
2664
+
2665
+ const proxy = new ScopeEnforcementProxy(manifest, config);
2666
+ const auditLogger = proxy.getAuditLogger();
2667
+
2668
+ // Add cloud backends if configured
2669
+ if (config.awsLogGroup) {
2670
+ auditLogger.addBackend(new CloudWatchAuditLogger(config.awsLogGroup, config.awsRegion));
2671
+ console.error(`[mcp-scope-proxy] CloudWatch logging enabled: ${config.awsLogGroup}`);
2672
+ }
2673
+ if (config.azureLogWorkspaceId && config.azureDcrImmutableId) {
2674
+ auditLogger.addBackend(
2675
+ new AzureMonitorAuditLogger(config.azureLogWorkspaceId, config.azureDcrImmutableId, config.azureDcrStreamName)
2676
+ );
2677
+ console.error("[mcp-scope-proxy] Azure Monitor logging enabled");
2678
+ }
2679
+ if (config.gcpProject) {
2680
+ auditLogger.addBackend(new GcpAuditLogger(config.gcpProject));
2681
+ console.error("[mcp-scope-proxy] GCP Cloud Logging enabled");
2682
+ }
2683
+
2684
+ if (config.transport === "stdio") {
2685
+ if (!config.upstreamMcpCommand) {
2686
+ console.error("[mcp-scope-proxy] ERROR: UPSTREAM_MCP_COMMAND is required for stdio transport");
2687
+ process.exit(1);
2688
+ }
2689
+ await runStdioProxy(proxy, config.upstreamMcpCommand, config.upstreamMcpArgs);
2690
+ } else {
2691
+ if (!config.upstreamMcpUrl) {
2692
+ console.error("[mcp-scope-proxy] ERROR: UPSTREAM_MCP_URL is required for HTTP transport");
2693
+ process.exit(1);
2694
+ }
2695
+ await runHttpProxy(proxy, config.upstreamMcpUrl, config.proxyPort);
2696
+ }
2697
+ }
2698
+
2699
+ main().catch((err) => {
2700
+ console.error(`[mcp-scope-proxy] Fatal error: ${err.message}`);
2701
+ process.exit(1);
2702
+ });
2703
+ ```
2704
+
2705
+ - [ ] **Step 2: Commit**
2706
+
2707
+ ```bash
2708
+ git add bin/mcp-scope-proxy.ts
2709
+ git commit -m "feat: add CLI entry point with transport and cloud backend wiring"
2710
+ ```
2711
+
2712
+ ---
2713
+
2714
+ ## Task 17: Integration Test
2715
+
2716
+ **Files:**
2717
+ - Create: `tests/integration/proxy.test.ts`
2718
+ - Create: `tests/fixtures/tool-calls.json`
2719
+
2720
+ - [ ] **Step 1: Create tool call fixtures**
2721
+
2722
+ Create `tests/fixtures/tool-calls.json`:
2723
+
2724
+ ```json
2725
+ {
2726
+ "allowed_nmap": {
2727
+ "jsonrpc": "2.0",
2728
+ "id": 1,
2729
+ "method": "tools/call",
2730
+ "params": {
2731
+ "name": "nmap_scan",
2732
+ "arguments": { "target": "10.10.10.50", "ports": "1-1024" }
2733
+ }
2734
+ },
2735
+ "blocked_out_of_scope": {
2736
+ "jsonrpc": "2.0",
2737
+ "id": 2,
2738
+ "method": "tools/call",
2739
+ "params": {
2740
+ "name": "nmap_scan",
2741
+ "arguments": { "target": "192.168.99.1", "ports": "1-1024" }
2742
+ }
2743
+ },
2744
+ "blocked_excluded": {
2745
+ "jsonrpc": "2.0",
2746
+ "id": 3,
2747
+ "method": "tools/call",
2748
+ "params": {
2749
+ "name": "nmap_scan",
2750
+ "arguments": { "target": "203.0.113.5" }
2751
+ }
2752
+ },
2753
+ "blocked_technique": {
2754
+ "jsonrpc": "2.0",
2755
+ "id": 4,
2756
+ "method": "tools/call",
2757
+ "params": {
2758
+ "name": "slowloris",
2759
+ "arguments": { "target": "10.10.10.50" }
2760
+ }
2761
+ },
2762
+ "pass_through_list": {
2763
+ "jsonrpc": "2.0",
2764
+ "id": 5,
2765
+ "method": "tools/list",
2766
+ "params": {}
2767
+ },
2768
+ "no_target_tool": {
2769
+ "jsonrpc": "2.0",
2770
+ "id": 6,
2771
+ "method": "tools/call",
2772
+ "params": {
2773
+ "name": "list_tools",
2774
+ "arguments": { "verbose": true }
2775
+ }
2776
+ }
2777
+ }
2778
+ ```
2779
+
2780
+ - [ ] **Step 2: Write integration tests**
2781
+
2782
+ Create `tests/integration/proxy.test.ts`:
2783
+
2784
+ ```typescript
2785
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2786
+ import { ScopeEnforcementProxy } from "../../src/proxy.js";
2787
+ import type { ScopeManifest } from "../../src/types.js";
2788
+ import { rm } from "fs/promises";
2789
+ import { join } from "path";
2790
+ import { tmpdir } from "os";
2791
+ import toolCalls from "../fixtures/tool-calls.json";
2792
+
2793
+ const auditDir = join(tmpdir(), "mcp-proxy-integration-" + Date.now());
2794
+
2795
+ const manifest: ScopeManifest = {
2796
+ engagement_id: "ENG-INT-TEST",
2797
+ client: "Integration Test Corp",
2798
+ operator: "test@integsec.com",
2799
+ authorized_targets: {
2800
+ ip_ranges: ["10.10.10.0/24", "203.0.113.0/28"],
2801
+ domains: ["*.acme.com", "acme-staging.example.com"],
2802
+ urls: ["https://app.acme.com"],
2803
+ cloud_accounts: [],
2804
+ },
2805
+ excluded_targets: ["203.0.113.5", "hr.acme.com"],
2806
+ authorized_techniques: ["recon", "web_app"],
2807
+ excluded_techniques: ["dos", "destructive", "social_engineering"],
2808
+ engagement_window: {
2809
+ start: "2020-01-01T00:00:00Z",
2810
+ end: "2099-12-31T23:59:59Z",
2811
+ },
2812
+ };
2813
+
2814
+ describe("ScopeEnforcementProxy integration", () => {
2815
+ let proxy: ScopeEnforcementProxy;
2816
+
2817
+ beforeEach(() => {
2818
+ proxy = new ScopeEnforcementProxy(manifest, {
2819
+ transport: "stdio",
2820
+ auditLogPath: auditDir,
2821
+ proxyPort: 9090,
2822
+ azureDcrStreamName: "test",
2823
+ dnsCacheTtl: 60,
2824
+ logLevel: "INFO",
2825
+ proxyVersion: "0.1.0-test",
2826
+ });
2827
+ });
2828
+
2829
+ afterEach(async () => {
2830
+ await proxy.getAuditLogger().stop();
2831
+ await rm(auditDir, { recursive: true, force: true }).catch(() => {});
2832
+ });
2833
+
2834
+ it("allows in-scope nmap scan", async () => {
2835
+ const result = await proxy.handleMessage(toolCalls.allowed_nmap);
2836
+ expect(result.forward).toBe(true);
2837
+ expect(result.response).toBeUndefined();
2838
+ });
2839
+
2840
+ it("blocks out-of-scope target", async () => {
2841
+ const result = await proxy.handleMessage(toolCalls.blocked_out_of_scope);
2842
+ expect(result.forward).toBe(false);
2843
+ expect((result.response as any).error.code).toBe(-32001);
2844
+ expect((result.response as any).error.message).toContain("BLOCKED_NOT_IN_SCOPE");
2845
+ });
2846
+
2847
+ it("blocks excluded target", async () => {
2848
+ const result = await proxy.handleMessage(toolCalls.blocked_excluded);
2849
+ expect(result.forward).toBe(false);
2850
+ expect((result.response as any).error.message).toContain("BLOCKED_EXCLUDED_TARGET");
2851
+ });
2852
+
2853
+ it("blocks blocked technique", async () => {
2854
+ const result = await proxy.handleMessage(toolCalls.blocked_technique);
2855
+ expect(result.forward).toBe(false);
2856
+ expect((result.response as any).error.message).toContain("BLOCKED_TECHNIQUE");
2857
+ });
2858
+
2859
+ it("passes through non-tools/call messages", async () => {
2860
+ const result = await proxy.handleMessage(toolCalls.pass_through_list);
2861
+ expect(result.forward).toBe(true);
2862
+ });
2863
+
2864
+ it("allows tool with no target parameter", async () => {
2865
+ const result = await proxy.handleMessage(toolCalls.no_target_tool);
2866
+ expect(result.forward).toBe(true);
2867
+ });
2868
+
2869
+ it("preserves JSON-RPC id in blocked response", async () => {
2870
+ const result = await proxy.handleMessage(toolCalls.blocked_out_of_scope);
2871
+ expect((result.response as any).id).toBe(2);
2872
+ expect((result.response as any).jsonrpc).toBe("2.0");
2873
+ });
2874
+ });
2875
+ ```
2876
+
2877
+ - [ ] **Step 3: Run all tests**
2878
+
2879
+ Run: `npx vitest run`
2880
+ Expected: All unit and integration tests pass.
2881
+
2882
+ - [ ] **Step 4: Commit**
2883
+
2884
+ ```bash
2885
+ git add tests/integration/ tests/fixtures/tool-calls.json
2886
+ git commit -m "test: add integration tests for proxy message handling"
2887
+ ```
2888
+
2889
+ ---
2890
+
2891
+ ## Task 18: Examples and Claude Desktop Config
2892
+
2893
+ **Files:**
2894
+ - Create: `examples/scope-manifest.json`
2895
+ - Create: `examples/technique-map.json`
2896
+ - Create: `examples/claude-desktop-config.json`
2897
+
2898
+ - [ ] **Step 1: Create example scope manifest**
2899
+
2900
+ Create `examples/scope-manifest.json`:
2901
+
2902
+ ```json
2903
+ {
2904
+ "engagement_id": "ENG-2025-0142",
2905
+ "client": "Acme Corp",
2906
+ "operator": "operator@integsec.com",
2907
+ "authorized_targets": {
2908
+ "ip_ranges": ["10.10.10.0/24", "203.0.113.0/28"],
2909
+ "domains": ["*.acme.com", "acme-staging.example.com"],
2910
+ "urls": ["https://app.acme.com", "https://api.acme.com"],
2911
+ "cloud_accounts": ["aws:123456789012", "azure:sub-xxxx"]
2912
+ },
2913
+ "excluded_targets": ["203.0.113.5", "hr.acme.com"],
2914
+ "authorized_techniques": ["recon", "web_app", "api_testing"],
2915
+ "excluded_techniques": ["dos", "destructive", "social_engineering"],
2916
+ "engagement_window": {
2917
+ "start": "2026-03-26T08:00:00Z",
2918
+ "end": "2026-04-09T17:00:00Z"
2919
+ }
2920
+ }
2921
+ ```
2922
+
2923
+ - [ ] **Step 2: Create example technique map**
2924
+
2925
+ Create `examples/technique-map.json`:
2926
+
2927
+ ```json
2928
+ {
2929
+ "wireless": {
2930
+ "exact": ["aircrack-ng", "wifite", "kismet"],
2931
+ "patterns": ["wifi", "wireless"]
2932
+ },
2933
+ "physical": {
2934
+ "exact": ["lockpick_sim"],
2935
+ "patterns": ["physical"]
2936
+ }
2937
+ }
2938
+ ```
2939
+
2940
+ - [ ] **Step 3: Create Claude Desktop config example**
2941
+
2942
+ Create `examples/claude-desktop-config.json`:
2943
+
2944
+ ```json
2945
+ {
2946
+ "mcpServers": {
2947
+ "kali-mcp-scoped": {
2948
+ "command": "npx",
2949
+ "args": ["-y", "mcp-scope-proxy"],
2950
+ "env": {
2951
+ "SCOPE_MANIFEST_PATH": "/path/to/scope.json",
2952
+ "MCP_TRANSPORT": "stdio",
2953
+ "UPSTREAM_MCP_COMMAND": "npx",
2954
+ "UPSTREAM_MCP_ARGS": "-y,@anthropic/kali-mcp",
2955
+ "AUDIT_LOG_PATH": "./audit/"
2956
+ }
2957
+ }
2958
+ }
2959
+ }
2960
+ ```
2961
+
2962
+ - [ ] **Step 4: Commit**
2963
+
2964
+ ```bash
2965
+ git add examples/
2966
+ git commit -m "docs: add example scope manifest, technique map, and Claude Desktop config"
2967
+ ```
2968
+
2969
+ ---
2970
+
2971
+ ## Task 19: Dockerfile
2972
+
2973
+ **Files:**
2974
+ - Create: `Dockerfile`
2975
+
2976
+ - [ ] **Step 1: Create Dockerfile**
2977
+
2978
+ ```dockerfile
2979
+ FROM node:20-slim AS builder
2980
+
2981
+ WORKDIR /app
2982
+ COPY package.json package-lock.json ./
2983
+ RUN npm ci
2984
+ COPY tsconfig.json ./
2985
+ COPY src/ ./src/
2986
+ COPY bin/ ./bin/
2987
+ RUN npm run build
2988
+
2989
+ FROM node:20-slim
2990
+
2991
+ RUN groupadd -r mcpproxy && useradd -r -g mcpproxy mcpproxy
2992
+
2993
+ WORKDIR /app
2994
+ COPY package.json package-lock.json ./
2995
+ RUN npm ci --omit=dev && npm cache clean --force
2996
+ COPY --from=builder /app/dist ./dist
2997
+
2998
+ USER mcpproxy
2999
+
3000
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
3001
+ CMD node -e "process.exit(0)"
3002
+
3003
+ ENTRYPOINT ["node", "dist/bin/mcp-scope-proxy.js"]
3004
+ ```
3005
+
3006
+ - [ ] **Step 2: Commit**
3007
+
3008
+ ```bash
3009
+ git add Dockerfile
3010
+ git commit -m "chore: add Dockerfile with non-root user and health check"
3011
+ ```
3012
+
3013
+ ---
3014
+
3015
+ ## Task 20: Push to GitHub
3016
+
3017
+ - [ ] **Step 1: Run full test suite**
3018
+
3019
+ Run: `npm test`
3020
+ Expected: All tests pass.
3021
+
3022
+ - [ ] **Step 2: Build**
3023
+
3024
+ Run: `npm run build`
3025
+ Expected: `dist/` directory created with no errors.
3026
+
3027
+ - [ ] **Step 3: Push to remote**
3028
+
3029
+ ```bash
3030
+ git push -u origin main
3031
+ ```
3032
+
3033
+ ---
3034
+
3035
+ ## Summary of test coverage vs. acceptance criteria
3036
+
3037
+ | Acceptance Criteria | Covered In |
3038
+ |---|---|
3039
+ | IP in authorized CIDR passes through | `validator.test.ts`, `proxy.test.ts` |
3040
+ | Out-of-scope IP blocked with MCP error | `validator.test.ts`, `proxy.test.ts` |
3041
+ | Excluded target blocked even if in range | `validator.test.ts`, `proxy.test.ts` |
3042
+ | Outside time window blocked | `validator.test.ts` |
3043
+ | Blocked technique blocked regardless | `validator.test.ts`, `proxy.test.ts` |
3044
+ | Local JSONL audit file | `audit-local.test.ts` |
3045
+ | Cloud logging integrations | `cloudwatch.ts`, `azure-monitor.ts`, `gcp-logging.ts` (runtime) |
3046
+ | Logging failures don't affect decisions | `audit/index.ts` error catching |
3047
+ | DNS failure causes block | `validator.test.ts` |
3048
+ | Credentials never logged | `sanitizer.test.ts` |
3049
+ | All pytest tests pass | N/A (vitest) — `npm test` |
3050
+ | Docker image builds | `Dockerfile` |
3051
+ | Claude Desktop config example | `examples/claude-desktop-config.json` |