@integsec/agentic-pentest-proxy 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/cloudflare/wrangler.jsonc +1 -1
- package/dist/bin/integsec-agentic-pentest-bridge.js +2 -2
- package/dist/bin/integsec-agentic-pentest-gateway.js +10 -3
- package/dist/bin/integsec-agentic-pentest-gateway.js.map +1 -1
- package/dist/src/config.js +1 -1
- package/dist/src/gateway/admin.html +28 -4
- package/dist/src/gateway/admin.js +70 -7
- package/dist/src/gateway/docs-home.html +20 -0
- package/dist/src/gateway/docs.d.ts +3 -0
- package/dist/src/gateway/docs.d.ts.map +1 -0
- package/dist/src/gateway/docs.html +12 -0
- package/dist/src/gateway/docs.js +94 -0
- package/dist/src/gateway/docs.js.map +1 -0
- package/dist/src/gateway/runtime.d.ts.map +1 -1
- package/dist/src/gateway/runtime.js +40 -3
- package/dist/src/gateway/runtime.js.map +1 -1
- package/dist/src/gateway/server.d.ts.map +1 -1
- package/dist/src/gateway/server.js +29 -0
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/state.d.ts +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/docs/configuration.md +70 -0
- package/docs/development.md +17 -0
- package/docs/images/tui-in-scope.png +0 -0
- package/docs/images/tui-in-scope.svg +24 -0
- package/docs/images/tui-out-of-scope.png +0 -0
- package/docs/images/tui-out-of-scope.svg +25 -0
- package/docs/images/tui-policy-blocks.png +0 -0
- package/docs/images/tui-policy-blocks.svg +24 -0
- package/docs/integrations.md +74 -0
- package/docs/managed-gateway.md +26 -3
- package/docs/release-readiness-0.2.6.md +27 -0
- package/docs/security.md +33 -0
- package/docs/superpowers/plans/2026-03-26-mcp-scope-proxy.md +3051 -0
- package/docs/superpowers/plans/2026-03-28-tui-monitor.md +863 -0
- package/docs/superpowers/plans/2026-03-29-manifest-builder.md +3429 -0
- package/docs/superpowers/specs/2026-03-28-tui-monitor-design.md +138 -0
- package/docs/superpowers/specs/2026-03-29-manifest-builder-design.md +248 -0
- package/docs/tui.md +25 -0
- 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` |
|