@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,3429 @@
|
|
|
1
|
+
# Scope Manifest Builder 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 CLI wizard and optional web companion for building, editing, reviewing, and sharing MCP scope manifests with smart validation and dry-run previews.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three layers — shared validation core (smart-validator, dry-run engine, manifest writer), Ink-based CLI wizard with 8 steps, and a Node http/https web server for client intake. The CLI is primary; the web server is on-demand via `serve` subcommand.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (ES2022, Node16 modules), React + Ink for TUI, Zod for validation, Node built-in http/https for web server, Vitest for tests. No new dependencies.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
src/
|
|
17
|
+
builder/
|
|
18
|
+
smart-validator.ts ← Tier 1-2 validation (shared core)
|
|
19
|
+
dry-run.ts ← Tier 3 dry-run engine (shared core)
|
|
20
|
+
manifest-writer.ts ← Serialize + write manifest to disk
|
|
21
|
+
wizard.tsx ← Ink wizard (main flow, step management)
|
|
22
|
+
steps/
|
|
23
|
+
engagement-info.tsx ← Step 1: ID, client, operator
|
|
24
|
+
engagement-window.tsx ← Step 2: start/end dates
|
|
25
|
+
domains.tsx ← Step 3: domain entry + live DNS
|
|
26
|
+
ip-ranges.tsx ← Step 4: CIDR entry + warnings
|
|
27
|
+
urls-and-cloud.tsx ← Step 5: URLs, cloud accounts
|
|
28
|
+
exclusions.tsx ← Step 6: excluded targets
|
|
29
|
+
techniques.tsx ← Step 7: technique multi-select
|
|
30
|
+
review.tsx ← Step 8: summary + dry-run table
|
|
31
|
+
web/
|
|
32
|
+
server.ts ← Node http/https server + TLS enforcement
|
|
33
|
+
api.ts ← GET /template, POST /submit handlers
|
|
34
|
+
client-form.html ← Single-file client intake form
|
|
35
|
+
|
|
36
|
+
bin/
|
|
37
|
+
integsec-agentic-pentest-manifest.ts ← CLI entry point (new/edit/review/serve)
|
|
38
|
+
|
|
39
|
+
tests/
|
|
40
|
+
unit/
|
|
41
|
+
builder/
|
|
42
|
+
smart-validator.test.ts
|
|
43
|
+
dry-run.test.ts
|
|
44
|
+
manifest-writer.test.ts
|
|
45
|
+
builder-web/
|
|
46
|
+
server.test.ts
|
|
47
|
+
api.test.ts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Phase 1: Shared Core + CLI Wizard (`new` mode)
|
|
53
|
+
|
|
54
|
+
### Task 1: Smart Validator — Tier 1 Format Validation
|
|
55
|
+
|
|
56
|
+
**Files:**
|
|
57
|
+
- Create: `src/builder/smart-validator.ts`
|
|
58
|
+
- Test: `tests/unit/builder/smart-validator.test.ts`
|
|
59
|
+
|
|
60
|
+
- [ ] **Step 1: Write failing tests for Tier 1 format validation**
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// tests/unit/builder/smart-validator.test.ts
|
|
64
|
+
import { describe, it, expect } from "vitest";
|
|
65
|
+
import {
|
|
66
|
+
validateEngagementId,
|
|
67
|
+
validateCidr,
|
|
68
|
+
validateDomain,
|
|
69
|
+
validateIsoDate,
|
|
70
|
+
validateWindowSanity,
|
|
71
|
+
type ValidationMessage,
|
|
72
|
+
} from "../../src/builder/smart-validator.js";
|
|
73
|
+
|
|
74
|
+
describe("SmartValidator — Tier 1: Format Validation", () => {
|
|
75
|
+
describe("validateEngagementId", () => {
|
|
76
|
+
it("accepts a valid engagement ID", () => {
|
|
77
|
+
const msgs = validateEngagementId("ENG-2026-0142");
|
|
78
|
+
expect(msgs).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects empty string", () => {
|
|
82
|
+
const msgs = validateEngagementId("");
|
|
83
|
+
expect(msgs).toHaveLength(1);
|
|
84
|
+
expect(msgs[0].level).toBe("error");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("suggests format when ID does not match ENG-YYYY-NNNN", () => {
|
|
88
|
+
const msgs = validateEngagementId("my-test");
|
|
89
|
+
expect(msgs).toHaveLength(1);
|
|
90
|
+
expect(msgs[0].level).toBe("info");
|
|
91
|
+
expect(msgs[0].message).toContain("ENG-YYYY-");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("validateCidr", () => {
|
|
96
|
+
it("accepts a valid CIDR", () => {
|
|
97
|
+
const msgs = validateCidr("10.10.10.0/24");
|
|
98
|
+
expect(msgs).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects invalid CIDR syntax", () => {
|
|
102
|
+
const msgs = validateCidr("not-a-cidr");
|
|
103
|
+
expect(msgs).toHaveLength(1);
|
|
104
|
+
expect(msgs[0].level).toBe("error");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("warns on broad range wider than /16", () => {
|
|
108
|
+
const msgs = validateCidr("10.0.0.0/8");
|
|
109
|
+
expect(msgs).toHaveLength(1);
|
|
110
|
+
expect(msgs[0].level).toBe("warning");
|
|
111
|
+
expect(msgs[0].message).toContain("16,777,216");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("accepts /16 without warning", () => {
|
|
115
|
+
const msgs = validateCidr("10.10.0.0/16");
|
|
116
|
+
expect(msgs).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("accepts bare IP as /32", () => {
|
|
120
|
+
const msgs = validateCidr("10.10.10.5");
|
|
121
|
+
expect(msgs).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("validateDomain", () => {
|
|
126
|
+
it("accepts a valid domain", () => {
|
|
127
|
+
const msgs = validateDomain("app.acme.com");
|
|
128
|
+
expect(msgs).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("accepts a wildcard domain", () => {
|
|
132
|
+
const msgs = validateDomain("*.acme.com");
|
|
133
|
+
expect(msgs).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects domain with spaces", () => {
|
|
137
|
+
const msgs = validateDomain("app .acme.com");
|
|
138
|
+
expect(msgs).toHaveLength(1);
|
|
139
|
+
expect(msgs[0].level).toBe("error");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects empty string", () => {
|
|
143
|
+
const msgs = validateDomain("");
|
|
144
|
+
expect(msgs).toHaveLength(1);
|
|
145
|
+
expect(msgs[0].level).toBe("error");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("validateIsoDate", () => {
|
|
150
|
+
it("accepts valid ISO 8601 datetime", () => {
|
|
151
|
+
const msgs = validateIsoDate("2026-03-29T08:00:00Z");
|
|
152
|
+
expect(msgs).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("accepts date-only string", () => {
|
|
156
|
+
const msgs = validateIsoDate("2026-03-29");
|
|
157
|
+
expect(msgs).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects invalid date", () => {
|
|
161
|
+
const msgs = validateIsoDate("not-a-date");
|
|
162
|
+
expect(msgs).toHaveLength(1);
|
|
163
|
+
expect(msgs[0].level).toBe("error");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects empty string", () => {
|
|
167
|
+
const msgs = validateIsoDate("");
|
|
168
|
+
expect(msgs).toHaveLength(1);
|
|
169
|
+
expect(msgs[0].level).toBe("error");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("validateWindowSanity", () => {
|
|
174
|
+
it("returns no messages for a normal window", () => {
|
|
175
|
+
const start = new Date(Date.now() + 3600_000).toISOString();
|
|
176
|
+
const end = new Date(Date.now() + 7 * 86400_000).toISOString();
|
|
177
|
+
const msgs = validateWindowSanity(start, end);
|
|
178
|
+
expect(msgs).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("warns if start is in the past", () => {
|
|
182
|
+
const start = "2020-01-01T00:00:00Z";
|
|
183
|
+
const end = new Date(Date.now() + 86400_000).toISOString();
|
|
184
|
+
const msgs = validateWindowSanity(start, end);
|
|
185
|
+
expect(msgs.some((m) => m.level === "warning" && m.message.includes("past"))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("warns if window exceeds 30 days", () => {
|
|
189
|
+
const start = new Date(Date.now() + 3600_000).toISOString();
|
|
190
|
+
const end = new Date(Date.now() + 60 * 86400_000).toISOString();
|
|
191
|
+
const msgs = validateWindowSanity(start, end);
|
|
192
|
+
expect(msgs.some((m) => m.level === "warning" && m.message.includes("30 days"))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("warns if window is less than 1 hour", () => {
|
|
196
|
+
const start = new Date(Date.now() + 3600_000).toISOString();
|
|
197
|
+
const end = new Date(Date.now() + 3600_000 + 1800_000).toISOString();
|
|
198
|
+
const msgs = validateWindowSanity(start, end);
|
|
199
|
+
expect(msgs.some((m) => m.level === "warning" && m.message.includes("1 hour"))).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("errors if end is before start", () => {
|
|
203
|
+
const start = new Date(Date.now() + 86400_000).toISOString();
|
|
204
|
+
const end = new Date(Date.now() - 86400_000).toISOString();
|
|
205
|
+
const msgs = validateWindowSanity(start, end);
|
|
206
|
+
expect(msgs.some((m) => m.level === "error" && m.message.includes("before"))).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
213
|
+
|
|
214
|
+
Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
|
|
215
|
+
Expected: FAIL — module not found
|
|
216
|
+
|
|
217
|
+
- [ ] **Step 3: Implement Tier 1 format validation**
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// src/builder/smart-validator.ts
|
|
221
|
+
|
|
222
|
+
import IPCIDR from "ip-cidr";
|
|
223
|
+
|
|
224
|
+
export interface ValidationMessage {
|
|
225
|
+
level: "error" | "warning" | "info";
|
|
226
|
+
message: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validate an engagement ID string.
|
|
231
|
+
* Accepts any non-empty string; suggests ENG-YYYY-NNNN format if it doesn't match.
|
|
232
|
+
*/
|
|
233
|
+
export function validateEngagementId(id: string): ValidationMessage[] {
|
|
234
|
+
if (!id || id.trim() === "") {
|
|
235
|
+
return [{ level: "error", message: "Engagement ID must not be empty." }];
|
|
236
|
+
}
|
|
237
|
+
if (!/^ENG-\d{4}-\d{3,}$/.test(id)) {
|
|
238
|
+
return [{ level: "info", message: `Suggested format: ENG-YYYY-NNNN (e.g. ENG-${new Date().getFullYear()}-0001).` }];
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Validate a CIDR range string.
|
|
245
|
+
* Accepts bare IPs (treated as /32 or /128). Warns on ranges broader than /16.
|
|
246
|
+
*/
|
|
247
|
+
export function validateCidr(cidr: string): ValidationMessage[] {
|
|
248
|
+
if (!cidr || cidr.trim() === "") {
|
|
249
|
+
return [{ level: "error", message: "CIDR range must not be empty." }];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Normalise bare IPs to CIDR notation for validation
|
|
253
|
+
let normalized = cidr;
|
|
254
|
+
if (!cidr.includes("/")) {
|
|
255
|
+
normalized = cidr.includes(":") ? `${cidr}/128` : `${cidr}/32`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!IPCIDR.isValidCIDR(normalized)) {
|
|
259
|
+
return [{ level: "error", message: `Invalid CIDR syntax: "${cidr}". Example: 10.10.10.0/24` }];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Warn on broad ranges (IPv4 only — /16 or wider)
|
|
263
|
+
const slashIndex = normalized.indexOf("/");
|
|
264
|
+
if (slashIndex !== -1 && !normalized.includes(":")) {
|
|
265
|
+
const prefix = parseInt(normalized.slice(slashIndex + 1), 10);
|
|
266
|
+
if (prefix < 16) {
|
|
267
|
+
const count = Math.pow(2, 32 - prefix);
|
|
268
|
+
return [{ level: "warning", message: `This range covers ${count.toLocaleString()} addresses. Is /${prefix} intentional?` }];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Validate a domain string.
|
|
277
|
+
* Accepts exact domains and wildcard domains (*.example.com).
|
|
278
|
+
*/
|
|
279
|
+
export function validateDomain(domain: string): ValidationMessage[] {
|
|
280
|
+
if (!domain || domain.trim() === "") {
|
|
281
|
+
return [{ level: "error", message: "Domain must not be empty." }];
|
|
282
|
+
}
|
|
283
|
+
if (/\s/.test(domain)) {
|
|
284
|
+
return [{ level: "error", message: `Domain "${domain}" contains whitespace.` }];
|
|
285
|
+
}
|
|
286
|
+
// Basic DNS label check: labels separated by dots, optional leading *.
|
|
287
|
+
const pattern = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z]{2,}\.?$/;
|
|
288
|
+
if (!pattern.test(domain)) {
|
|
289
|
+
return [{ level: "error", message: `"${domain}" does not look like a valid domain.` }];
|
|
290
|
+
}
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Validate an ISO 8601 date string.
|
|
296
|
+
*/
|
|
297
|
+
export function validateIsoDate(date: string): ValidationMessage[] {
|
|
298
|
+
if (!date || date.trim() === "") {
|
|
299
|
+
return [{ level: "error", message: "Date must not be empty." }];
|
|
300
|
+
}
|
|
301
|
+
if (isNaN(Date.parse(date))) {
|
|
302
|
+
return [{ level: "error", message: `"${date}" is not a valid ISO 8601 date. Example: 2026-03-29T08:00:00Z` }];
|
|
303
|
+
}
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Validate the engagement window for sanity (start/end relationship, duration).
|
|
309
|
+
* Assumes both dates have already passed format validation.
|
|
310
|
+
*/
|
|
311
|
+
export function validateWindowSanity(start: string, end: string): ValidationMessage[] {
|
|
312
|
+
const msgs: ValidationMessage[] = [];
|
|
313
|
+
const startMs = Date.parse(start);
|
|
314
|
+
const endMs = Date.parse(end);
|
|
315
|
+
|
|
316
|
+
if (isNaN(startMs) || isNaN(endMs)) {
|
|
317
|
+
return []; // Format validation handles this
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (endMs <= startMs) {
|
|
321
|
+
msgs.push({ level: "error", message: "End date is before or equal to start date." });
|
|
322
|
+
return msgs; // No point checking duration
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (startMs < Date.now()) {
|
|
326
|
+
msgs.push({ level: "warning", message: "Start date is in the past." });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const durationMs = endMs - startMs;
|
|
330
|
+
const thirtyDaysMs = 30 * 24 * 3600_000;
|
|
331
|
+
const oneHourMs = 3600_000;
|
|
332
|
+
|
|
333
|
+
if (durationMs > thirtyDaysMs) {
|
|
334
|
+
msgs.push({ level: "warning", message: `Engagement window exceeds 30 days (${Math.round(durationMs / 86400_000)} days).` });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (durationMs < oneHourMs) {
|
|
338
|
+
msgs.push({ level: "warning", message: "Engagement window is less than 1 hour." });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return msgs;
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
346
|
+
|
|
347
|
+
Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
|
|
348
|
+
Expected: All tests PASS
|
|
349
|
+
|
|
350
|
+
- [ ] **Step 5: Commit**
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
git add src/builder/smart-validator.ts tests/unit/builder/smart-validator.test.ts
|
|
354
|
+
git commit -m "feat(builder): add Tier 1 format validation for smart-validator"
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
### Task 2: Smart Validator — Tier 2 Async Validation
|
|
360
|
+
|
|
361
|
+
**Files:**
|
|
362
|
+
- Modify: `src/builder/smart-validator.ts`
|
|
363
|
+
- Modify: `tests/unit/builder/smart-validator.test.ts`
|
|
364
|
+
|
|
365
|
+
- [ ] **Step 1: Write failing tests for Tier 2 async validation**
|
|
366
|
+
|
|
367
|
+
Add to `tests/unit/builder/smart-validator.test.ts`:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
import {
|
|
371
|
+
validateEngagementId,
|
|
372
|
+
validateCidr,
|
|
373
|
+
validateDomain,
|
|
374
|
+
validateIsoDate,
|
|
375
|
+
validateWindowSanity,
|
|
376
|
+
resolveDomainPreview,
|
|
377
|
+
checkOverlap,
|
|
378
|
+
checkWildcardApex,
|
|
379
|
+
checkOrphanedExclusion,
|
|
380
|
+
type ValidationMessage,
|
|
381
|
+
} from "../../src/builder/smart-validator.js";
|
|
382
|
+
import { DnsResolver } from "../../src/dns-resolver.js";
|
|
383
|
+
import { vi } from "vitest";
|
|
384
|
+
|
|
385
|
+
// ... existing tests ...
|
|
386
|
+
|
|
387
|
+
describe("SmartValidator — Tier 2: Async Validation", () => {
|
|
388
|
+
function mockResolver(resolveImpl: (target: string) => Promise<string[]>): DnsResolver {
|
|
389
|
+
const resolver = new DnsResolver();
|
|
390
|
+
resolver.resolve = vi.fn(resolveImpl);
|
|
391
|
+
return resolver;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
describe("resolveDomainPreview", () => {
|
|
395
|
+
it("returns resolved IPs for a domain", async () => {
|
|
396
|
+
const resolver = mockResolver(async () => ["10.10.10.1", "10.10.10.2"]);
|
|
397
|
+
const result = await resolveDomainPreview("app.acme.com", resolver);
|
|
398
|
+
expect(result.ips).toEqual(["10.10.10.1", "10.10.10.2"]);
|
|
399
|
+
expect(result.messages).toEqual([]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("returns warning when DNS resolution fails", async () => {
|
|
403
|
+
const resolver = mockResolver(async () => []);
|
|
404
|
+
const result = await resolveDomainPreview("nonexistent.example.com", resolver);
|
|
405
|
+
expect(result.ips).toEqual([]);
|
|
406
|
+
expect(result.messages).toHaveLength(1);
|
|
407
|
+
expect(result.messages[0].level).toBe("warning");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("checkOverlap", () => {
|
|
412
|
+
it("returns info when domain resolves to IP already in an authorized range", () => {
|
|
413
|
+
const msgs = checkOverlap(["10.10.10.5"], ["10.10.10.0/24"]);
|
|
414
|
+
expect(msgs).toHaveLength(1);
|
|
415
|
+
expect(msgs[0].level).toBe("info");
|
|
416
|
+
expect(msgs[0].message).toContain("already covered");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("returns no message when IPs are not in any authorized range", () => {
|
|
420
|
+
const msgs = checkOverlap(["8.8.8.8"], ["10.10.10.0/24"]);
|
|
421
|
+
expect(msgs).toEqual([]);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("checkWildcardApex", () => {
|
|
426
|
+
it("returns info suggesting apex when wildcard is added", () => {
|
|
427
|
+
const msgs = checkWildcardApex("*.acme.com", ["*.acme.com"]);
|
|
428
|
+
expect(msgs).toHaveLength(1);
|
|
429
|
+
expect(msgs[0].level).toBe("info");
|
|
430
|
+
expect(msgs[0].message).toContain("acme.com");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("returns no message when apex is already in the list", () => {
|
|
434
|
+
const msgs = checkWildcardApex("*.acme.com", ["*.acme.com", "acme.com"]);
|
|
435
|
+
expect(msgs).toEqual([]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("returns no message for non-wildcard domain", () => {
|
|
439
|
+
const msgs = checkWildcardApex("app.acme.com", ["app.acme.com"]);
|
|
440
|
+
expect(msgs).toEqual([]);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("checkOrphanedExclusion", () => {
|
|
445
|
+
it("warns when excluded target is not in any authorized range or domain", () => {
|
|
446
|
+
const msgs = checkOrphanedExclusion(
|
|
447
|
+
"8.8.8.8",
|
|
448
|
+
{ ip_ranges: ["10.10.10.0/24"], domains: ["*.acme.com"] }
|
|
449
|
+
);
|
|
450
|
+
expect(msgs).toHaveLength(1);
|
|
451
|
+
expect(msgs[0].level).toBe("warning");
|
|
452
|
+
expect(msgs[0].message).toContain("redundant");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("returns no message when excluded IP is in an authorized range", () => {
|
|
456
|
+
const msgs = checkOrphanedExclusion(
|
|
457
|
+
"10.10.10.5",
|
|
458
|
+
{ ip_ranges: ["10.10.10.0/24"], domains: [] }
|
|
459
|
+
);
|
|
460
|
+
expect(msgs).toEqual([]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("returns no message when excluded domain matches an authorized pattern", () => {
|
|
464
|
+
const msgs = checkOrphanedExclusion(
|
|
465
|
+
"hr.acme.com",
|
|
466
|
+
{ ip_ranges: [], domains: ["*.acme.com"] }
|
|
467
|
+
);
|
|
468
|
+
expect(msgs).toEqual([]);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
- [ ] **Step 2: Run tests to verify Tier 2 tests fail**
|
|
475
|
+
|
|
476
|
+
Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
|
|
477
|
+
Expected: FAIL — new functions not exported
|
|
478
|
+
|
|
479
|
+
- [ ] **Step 3: Implement Tier 2 async validation**
|
|
480
|
+
|
|
481
|
+
Add to `src/builder/smart-validator.ts`:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { DnsResolver } from "../dns-resolver.js";
|
|
485
|
+
import { isIpInCidr } from "../ip-matcher.js";
|
|
486
|
+
import { matchesDomain } from "../domain-matcher.js";
|
|
487
|
+
|
|
488
|
+
// Add to existing imports at top of file
|
|
489
|
+
|
|
490
|
+
export interface DomainPreviewResult {
|
|
491
|
+
ips: string[];
|
|
492
|
+
messages: ValidationMessage[];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Resolve a domain and return its IPs with any warnings.
|
|
497
|
+
*/
|
|
498
|
+
export async function resolveDomainPreview(
|
|
499
|
+
domain: string,
|
|
500
|
+
resolver: DnsResolver
|
|
501
|
+
): Promise<DomainPreviewResult> {
|
|
502
|
+
// Strip wildcard prefix for resolution
|
|
503
|
+
const lookupDomain = domain.startsWith("*.") ? domain.slice(2) : domain;
|
|
504
|
+
const ips = await resolver.resolve(lookupDomain);
|
|
505
|
+
|
|
506
|
+
if (ips.length === 0) {
|
|
507
|
+
return {
|
|
508
|
+
ips: [],
|
|
509
|
+
messages: [{ level: "warning", message: `DNS resolution for "${lookupDomain}" returned no addresses.` }],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return { ips, messages: [] };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Check if resolved IPs are already covered by authorized CIDR ranges.
|
|
518
|
+
*/
|
|
519
|
+
export function checkOverlap(
|
|
520
|
+
resolvedIps: string[],
|
|
521
|
+
authorizedRanges: string[]
|
|
522
|
+
): ValidationMessage[] {
|
|
523
|
+
for (const ip of resolvedIps) {
|
|
524
|
+
for (const range of authorizedRanges) {
|
|
525
|
+
if (isIpInCidr(ip, range)) {
|
|
526
|
+
return [{ level: "info", message: `Resolved IP ${ip} is already covered by authorized range ${range}.` }];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* If a wildcard domain is being added, check whether the apex is also included.
|
|
535
|
+
*/
|
|
536
|
+
export function checkWildcardApex(
|
|
537
|
+
domain: string,
|
|
538
|
+
currentDomains: string[]
|
|
539
|
+
): ValidationMessage[] {
|
|
540
|
+
if (!domain.startsWith("*.")) return [];
|
|
541
|
+
|
|
542
|
+
const apex = domain.slice(2);
|
|
543
|
+
const hasApex = currentDomains.some(
|
|
544
|
+
(d) => d.toLowerCase() === apex.toLowerCase()
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
if (!hasApex) {
|
|
548
|
+
return [{
|
|
549
|
+
level: "info",
|
|
550
|
+
message: `Wildcard "${domain}" does not cover the apex "${apex}". Add "${apex}" separately if needed.`,
|
|
551
|
+
}];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Check if an excluded target actually falls within any authorized range or domain.
|
|
559
|
+
* If not, the exclusion is redundant.
|
|
560
|
+
*/
|
|
561
|
+
export function checkOrphanedExclusion(
|
|
562
|
+
excludedTarget: string,
|
|
563
|
+
authorized: { ip_ranges: string[]; domains: string[] }
|
|
564
|
+
): ValidationMessage[] {
|
|
565
|
+
// Check if it matches an authorized IP range
|
|
566
|
+
for (const range of authorized.ip_ranges) {
|
|
567
|
+
if (isIpInCidr(excludedTarget, range)) {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check if it matches an authorized domain pattern
|
|
573
|
+
for (const pattern of authorized.domains) {
|
|
574
|
+
if (matchesDomain(excludedTarget, pattern)) {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return [{
|
|
580
|
+
level: "warning",
|
|
581
|
+
message: `Excluded target "${excludedTarget}" does not fall within any authorized range or domain — this exclusion is redundant.`,
|
|
582
|
+
}];
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
587
|
+
|
|
588
|
+
Run: `npx vitest run tests/unit/builder/smart-validator.test.ts`
|
|
589
|
+
Expected: All tests PASS
|
|
590
|
+
|
|
591
|
+
- [ ] **Step 5: Commit**
|
|
592
|
+
|
|
593
|
+
```bash
|
|
594
|
+
git add src/builder/smart-validator.ts tests/unit/builder/smart-validator.test.ts
|
|
595
|
+
git commit -m "feat(builder): add Tier 2 async validation (DNS preview, overlap, wildcard, orphan)"
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
### Task 3: Dry-Run Engine
|
|
601
|
+
|
|
602
|
+
**Files:**
|
|
603
|
+
- Create: `src/builder/dry-run.ts`
|
|
604
|
+
- Test: `tests/unit/builder/dry-run.test.ts`
|
|
605
|
+
|
|
606
|
+
- [ ] **Step 1: Write failing tests for dry-run engine**
|
|
607
|
+
|
|
608
|
+
```typescript
|
|
609
|
+
// tests/unit/builder/dry-run.test.ts
|
|
610
|
+
import { describe, it, expect, vi } from "vitest";
|
|
611
|
+
import { runDryRun, type DryRunResult } from "../../src/builder/dry-run.js";
|
|
612
|
+
import type { ScopeManifest } from "../../src/types.js";
|
|
613
|
+
import { DnsResolver } from "../../src/dns-resolver.js";
|
|
614
|
+
|
|
615
|
+
const testManifest: ScopeManifest = {
|
|
616
|
+
engagement_id: "ENG-2026-0001",
|
|
617
|
+
client: "Test Corp",
|
|
618
|
+
operator: "test@test.com",
|
|
619
|
+
authorized_targets: {
|
|
620
|
+
ip_ranges: ["10.10.10.0/24"],
|
|
621
|
+
domains: ["*.acme.com"],
|
|
622
|
+
urls: [],
|
|
623
|
+
cloud_accounts: [],
|
|
624
|
+
},
|
|
625
|
+
excluded_targets: ["hr.acme.com"],
|
|
626
|
+
authorized_techniques: ["recon", "web_app"],
|
|
627
|
+
excluded_techniques: ["dos", "destructive"],
|
|
628
|
+
engagement_window: {
|
|
629
|
+
start: "2020-01-01T00:00:00Z",
|
|
630
|
+
end: "2099-12-31T23:59:59Z",
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
function mockResolver(): DnsResolver {
|
|
635
|
+
const resolver = new DnsResolver();
|
|
636
|
+
resolver.resolve = vi.fn(async (target: string) => {
|
|
637
|
+
const map: Record<string, string[]> = {
|
|
638
|
+
"app.acme.com": ["10.10.10.1"],
|
|
639
|
+
"hr.acme.com": ["10.10.10.2"],
|
|
640
|
+
"acme.com": ["10.10.10.3"],
|
|
641
|
+
};
|
|
642
|
+
if (target in map) return map[target];
|
|
643
|
+
// Bare IPs pass through
|
|
644
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(target)) return [target];
|
|
645
|
+
return [];
|
|
646
|
+
});
|
|
647
|
+
return resolver;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
describe("DryRunEngine", () => {
|
|
651
|
+
it("returns ALLOWED for authorized domain", async () => {
|
|
652
|
+
const results = await runDryRun(testManifest, mockResolver());
|
|
653
|
+
const appResult = results.find((r) => r.target === "app.acme.com");
|
|
654
|
+
expect(appResult).toBeDefined();
|
|
655
|
+
expect(appResult!.actual).toBe("ALLOWED");
|
|
656
|
+
expect(appResult!.pass).toBe(true);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("returns BLOCKED for excluded domain", async () => {
|
|
660
|
+
const results = await runDryRun(testManifest, mockResolver());
|
|
661
|
+
const hrResult = results.find((r) => r.target === "hr.acme.com");
|
|
662
|
+
expect(hrResult).toBeDefined();
|
|
663
|
+
expect(hrResult!.actual).toContain("BLOCKED");
|
|
664
|
+
expect(hrResult!.pass).toBe(true); // Expected to be blocked
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("returns BLOCKED for out-of-scope target", async () => {
|
|
668
|
+
const results = await runDryRun(testManifest, mockResolver());
|
|
669
|
+
const oosResult = results.find((r) => r.target === "8.8.8.8");
|
|
670
|
+
expect(oosResult).toBeDefined();
|
|
671
|
+
expect(oosResult!.actual).toContain("BLOCKED");
|
|
672
|
+
expect(oosResult!.pass).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("returns BLOCKED for cloud metadata IP", async () => {
|
|
676
|
+
const results = await runDryRun(testManifest, mockResolver());
|
|
677
|
+
const metaResult = results.find((r) => r.target === "169.254.169.254");
|
|
678
|
+
expect(metaResult).toBeDefined();
|
|
679
|
+
expect(metaResult!.actual).toContain("BLOCKED");
|
|
680
|
+
expect(metaResult!.pass).toBe(true);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("returns ALLOWED for IP in authorized range", async () => {
|
|
684
|
+
const results = await runDryRun(testManifest, mockResolver());
|
|
685
|
+
const ipResult = results.find((r) => r.target === "10.10.10.50");
|
|
686
|
+
expect(ipResult).toBeDefined();
|
|
687
|
+
expect(ipResult!.actual).toBe("ALLOWED");
|
|
688
|
+
expect(ipResult!.pass).toBe(true);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("flags surprises when actual differs from expected", async () => {
|
|
692
|
+
// Manifest with no authorized targets — everything should be blocked
|
|
693
|
+
const emptyManifest: ScopeManifest = {
|
|
694
|
+
...testManifest,
|
|
695
|
+
authorized_targets: { ip_ranges: [], domains: [], urls: [], cloud_accounts: [] },
|
|
696
|
+
excluded_targets: [],
|
|
697
|
+
};
|
|
698
|
+
const resolver = mockResolver();
|
|
699
|
+
const results = await runDryRun(emptyManifest, resolver);
|
|
700
|
+
// All domain/IP tests should fail (expected ALLOWED but got BLOCKED)
|
|
701
|
+
const surprises = results.filter((r) => !r.pass);
|
|
702
|
+
expect(surprises.length).toBeGreaterThan(0);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
708
|
+
|
|
709
|
+
Run: `npx vitest run tests/unit/builder/dry-run.test.ts`
|
|
710
|
+
Expected: FAIL — module not found
|
|
711
|
+
|
|
712
|
+
- [ ] **Step 3: Implement dry-run engine**
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
// src/builder/dry-run.ts
|
|
716
|
+
|
|
717
|
+
import type { ScopeManifest, Decision } from "../types.js";
|
|
718
|
+
import { ScopeValidator } from "../validator.js";
|
|
719
|
+
import { DnsResolver } from "../dns-resolver.js";
|
|
720
|
+
import { isIpInCidr } from "../ip-matcher.js";
|
|
721
|
+
|
|
722
|
+
export interface DryRunResult {
|
|
723
|
+
target: string;
|
|
724
|
+
tool: string;
|
|
725
|
+
expected: "ALLOWED" | "BLOCKED";
|
|
726
|
+
actual: Decision;
|
|
727
|
+
pass: boolean;
|
|
728
|
+
reason: string;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Run a battery of synthetic tool calls against a manifest to preview
|
|
733
|
+
* what the proxy would allow and block.
|
|
734
|
+
*/
|
|
735
|
+
export async function runDryRun(
|
|
736
|
+
manifest: ScopeManifest,
|
|
737
|
+
resolver?: DnsResolver
|
|
738
|
+
): Promise<DryRunResult[]> {
|
|
739
|
+
const dnsResolver = resolver ?? new DnsResolver(0); // TTL 0 = no caching for fresh results
|
|
740
|
+
const validator = new ScopeValidator(manifest, dnsResolver);
|
|
741
|
+
const results: DryRunResult[] = [];
|
|
742
|
+
|
|
743
|
+
// Test each authorized domain — should be ALLOWED
|
|
744
|
+
for (const domain of manifest.authorized_targets.domains) {
|
|
745
|
+
// Skip wildcards; generate a sample subdomain instead
|
|
746
|
+
const testDomain = domain.startsWith("*.")
|
|
747
|
+
? `test-sample.${domain.slice(2)}`
|
|
748
|
+
: domain;
|
|
749
|
+
|
|
750
|
+
const result = await validator.validate("nmap", { target: testDomain });
|
|
751
|
+
results.push({
|
|
752
|
+
target: testDomain,
|
|
753
|
+
tool: "nmap",
|
|
754
|
+
expected: "ALLOWED",
|
|
755
|
+
actual: result.decision,
|
|
756
|
+
pass: result.decision === "ALLOWED",
|
|
757
|
+
reason: result.reason,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Test a sample IP from each authorized range — should be ALLOWED
|
|
762
|
+
for (const range of manifest.authorized_targets.ip_ranges) {
|
|
763
|
+
const sampleIp = sampleIpFromCidr(range);
|
|
764
|
+
if (!sampleIp) continue;
|
|
765
|
+
|
|
766
|
+
const result = await validator.validate("nmap", { target: sampleIp });
|
|
767
|
+
results.push({
|
|
768
|
+
target: sampleIp,
|
|
769
|
+
tool: "nmap",
|
|
770
|
+
expected: "ALLOWED",
|
|
771
|
+
actual: result.decision,
|
|
772
|
+
pass: result.decision === "ALLOWED",
|
|
773
|
+
reason: result.reason,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Test each excluded target — should be BLOCKED
|
|
778
|
+
for (const excluded of manifest.excluded_targets) {
|
|
779
|
+
const result = await validator.validate("nmap", { target: excluded });
|
|
780
|
+
results.push({
|
|
781
|
+
target: excluded,
|
|
782
|
+
tool: "nmap",
|
|
783
|
+
expected: "BLOCKED",
|
|
784
|
+
actual: result.decision,
|
|
785
|
+
pass: result.decision.startsWith("BLOCKED"),
|
|
786
|
+
reason: result.reason,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Test sensitive targets — should be BLOCKED (unless explicitly authorized)
|
|
791
|
+
const sensitiveTargets = ["169.254.169.254", "8.8.8.8"];
|
|
792
|
+
for (const target of sensitiveTargets) {
|
|
793
|
+
const result = await validator.validate("curl", { url: `http://${target}/` });
|
|
794
|
+
// These are expected to be blocked unless the manifest explicitly authorizes them
|
|
795
|
+
const isAuthorized = manifest.authorized_targets.ip_ranges.some((range) =>
|
|
796
|
+
isIpInCidr(target, range)
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
results.push({
|
|
800
|
+
target,
|
|
801
|
+
tool: "curl",
|
|
802
|
+
expected: isAuthorized ? "ALLOWED" : "BLOCKED",
|
|
803
|
+
actual: result.decision,
|
|
804
|
+
pass: isAuthorized
|
|
805
|
+
? result.decision === "ALLOWED"
|
|
806
|
+
: result.decision.startsWith("BLOCKED"),
|
|
807
|
+
reason: result.reason,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return results;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Extract a sample IP from a CIDR range for testing.
|
|
816
|
+
* For /32, returns the IP itself. For wider ranges, picks .50 in the last octet
|
|
817
|
+
* (or the first usable address for very small ranges).
|
|
818
|
+
*/
|
|
819
|
+
function sampleIpFromCidr(cidr: string): string | null {
|
|
820
|
+
// Handle bare IPs
|
|
821
|
+
if (!cidr.includes("/")) return cidr;
|
|
822
|
+
|
|
823
|
+
const [base, prefix] = cidr.split("/");
|
|
824
|
+
const prefixNum = parseInt(prefix, 10);
|
|
825
|
+
|
|
826
|
+
// For /32 or /128, just return the base IP
|
|
827
|
+
if (prefixNum >= 32 || (base.includes(":") && prefixNum >= 128)) {
|
|
828
|
+
return base;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// For IPv4, generate a sample by modifying the last octet
|
|
832
|
+
if (!base.includes(":")) {
|
|
833
|
+
const parts = base.split(".");
|
|
834
|
+
const lastOctet = parseInt(parts[3], 10);
|
|
835
|
+
// Pick an offset that stays in range
|
|
836
|
+
const offset = Math.min(50, Math.pow(2, 32 - prefixNum) - 2);
|
|
837
|
+
parts[3] = String(lastOctet + offset);
|
|
838
|
+
return parts.join(".");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// For IPv6, just return the base (good enough for dry-run)
|
|
842
|
+
return base;
|
|
843
|
+
}
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
847
|
+
|
|
848
|
+
Run: `npx vitest run tests/unit/builder/dry-run.test.ts`
|
|
849
|
+
Expected: All tests PASS
|
|
850
|
+
|
|
851
|
+
- [ ] **Step 5: Commit**
|
|
852
|
+
|
|
853
|
+
```bash
|
|
854
|
+
git add src/builder/dry-run.ts tests/unit/builder/dry-run.test.ts
|
|
855
|
+
git commit -m "feat(builder): add dry-run engine for scope manifest preview"
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
---
|
|
859
|
+
|
|
860
|
+
### Task 4: Manifest Writer
|
|
861
|
+
|
|
862
|
+
**Files:**
|
|
863
|
+
- Create: `src/builder/manifest-writer.ts`
|
|
864
|
+
- Test: `tests/unit/builder/manifest-writer.test.ts`
|
|
865
|
+
|
|
866
|
+
- [ ] **Step 1: Write failing tests for manifest writer**
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
// tests/unit/builder/manifest-writer.test.ts
|
|
870
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
871
|
+
import fs from "fs";
|
|
872
|
+
import path from "path";
|
|
873
|
+
import {
|
|
874
|
+
writeManifest,
|
|
875
|
+
serializeManifest,
|
|
876
|
+
} from "../../src/builder/manifest-writer.js";
|
|
877
|
+
import type { ScopeManifest } from "../../src/types.js";
|
|
878
|
+
|
|
879
|
+
const testManifest: ScopeManifest = {
|
|
880
|
+
engagement_id: "ENG-2026-0001",
|
|
881
|
+
client: "Test Corp",
|
|
882
|
+
operator: "test@test.com",
|
|
883
|
+
authorized_targets: {
|
|
884
|
+
ip_ranges: ["10.10.10.0/24"],
|
|
885
|
+
domains: ["*.acme.com"],
|
|
886
|
+
urls: [],
|
|
887
|
+
cloud_accounts: [],
|
|
888
|
+
},
|
|
889
|
+
excluded_targets: [],
|
|
890
|
+
authorized_techniques: ["recon"],
|
|
891
|
+
excluded_techniques: ["dos"],
|
|
892
|
+
engagement_window: {
|
|
893
|
+
start: "2026-01-01T00:00:00Z",
|
|
894
|
+
end: "2026-02-01T00:00:00Z",
|
|
895
|
+
},
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
describe("ManifestWriter", () => {
|
|
899
|
+
describe("serializeManifest", () => {
|
|
900
|
+
it("returns pretty-printed JSON", () => {
|
|
901
|
+
const json = serializeManifest(testManifest);
|
|
902
|
+
expect(JSON.parse(json)).toEqual(testManifest);
|
|
903
|
+
expect(json).toContain("\n"); // Pretty-printed
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("output is valid JSON that round-trips", () => {
|
|
907
|
+
const json = serializeManifest(testManifest);
|
|
908
|
+
const parsed = JSON.parse(json);
|
|
909
|
+
expect(parsed.engagement_id).toBe("ENG-2026-0001");
|
|
910
|
+
expect(parsed.authorized_targets.domains).toEqual(["*.acme.com"]);
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
describe("writeManifest", () => {
|
|
915
|
+
const testDir = path.join(process.cwd(), "test-output-manifest-writer");
|
|
916
|
+
const testPath = path.join(testDir, "test-scope.json");
|
|
917
|
+
|
|
918
|
+
afterEach(() => {
|
|
919
|
+
if (fs.existsSync(testDir)) {
|
|
920
|
+
fs.rmSync(testDir, { recursive: true });
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("writes manifest to the specified path", () => {
|
|
925
|
+
writeManifest(testManifest, testPath);
|
|
926
|
+
const content = fs.readFileSync(testPath, "utf-8");
|
|
927
|
+
expect(JSON.parse(content)).toEqual(testManifest);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("creates parent directories if they do not exist", () => {
|
|
931
|
+
const nestedPath = path.join(testDir, "nested", "deep", "scope.json");
|
|
932
|
+
writeManifest(testManifest, nestedPath);
|
|
933
|
+
expect(fs.existsSync(nestedPath)).toBe(true);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("refuses to overwrite existing file without force flag", () => {
|
|
937
|
+
writeManifest(testManifest, testPath);
|
|
938
|
+
expect(() => writeManifest(testManifest, testPath)).toThrow("already exists");
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("overwrites existing file with force flag", () => {
|
|
942
|
+
writeManifest(testManifest, testPath);
|
|
943
|
+
const updated = { ...testManifest, client: "Updated Corp" };
|
|
944
|
+
writeManifest(updated, testPath, { force: true });
|
|
945
|
+
const content = fs.readFileSync(testPath, "utf-8");
|
|
946
|
+
expect(JSON.parse(content).client).toBe("Updated Corp");
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
953
|
+
|
|
954
|
+
Run: `npx vitest run tests/unit/builder/manifest-writer.test.ts`
|
|
955
|
+
Expected: FAIL — module not found
|
|
956
|
+
|
|
957
|
+
- [ ] **Step 3: Implement manifest writer**
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
// src/builder/manifest-writer.ts
|
|
961
|
+
|
|
962
|
+
import fs from "fs";
|
|
963
|
+
import path from "path";
|
|
964
|
+
import type { ScopeManifest } from "../types.js";
|
|
965
|
+
|
|
966
|
+
export interface WriteOptions {
|
|
967
|
+
force?: boolean;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Serialize a ScopeManifest to pretty-printed JSON.
|
|
972
|
+
*/
|
|
973
|
+
export function serializeManifest(manifest: ScopeManifest): string {
|
|
974
|
+
return JSON.stringify(manifest, null, 2) + "\n";
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Write a manifest to disk. Creates parent directories if needed.
|
|
979
|
+
* Throws if the file already exists unless force is true.
|
|
980
|
+
*/
|
|
981
|
+
export function writeManifest(
|
|
982
|
+
manifest: ScopeManifest,
|
|
983
|
+
filePath: string,
|
|
984
|
+
options: WriteOptions = {}
|
|
985
|
+
): void {
|
|
986
|
+
const dir = path.dirname(filePath);
|
|
987
|
+
if (!fs.existsSync(dir)) {
|
|
988
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (fs.existsSync(filePath) && !options.force) {
|
|
992
|
+
throw new Error(`File "${filePath}" already exists. Use --force to overwrite.`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const json = serializeManifest(manifest);
|
|
996
|
+
fs.writeFileSync(filePath, json, "utf-8");
|
|
997
|
+
}
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1001
|
+
|
|
1002
|
+
Run: `npx vitest run tests/unit/builder/manifest-writer.test.ts`
|
|
1003
|
+
Expected: All tests PASS
|
|
1004
|
+
|
|
1005
|
+
- [ ] **Step 5: Commit**
|
|
1006
|
+
|
|
1007
|
+
```bash
|
|
1008
|
+
git add src/builder/manifest-writer.ts tests/unit/builder/manifest-writer.test.ts
|
|
1009
|
+
git commit -m "feat(builder): add manifest writer with overwrite protection"
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
---
|
|
1013
|
+
|
|
1014
|
+
### Task 5: Ink Wizard — Step Components (Steps 1–5)
|
|
1015
|
+
|
|
1016
|
+
**Files:**
|
|
1017
|
+
- Create: `src/builder/steps/engagement-info.tsx`
|
|
1018
|
+
- Create: `src/builder/steps/engagement-window.tsx`
|
|
1019
|
+
- Create: `src/builder/steps/domains.tsx`
|
|
1020
|
+
- Create: `src/builder/steps/ip-ranges.tsx`
|
|
1021
|
+
- Create: `src/builder/steps/urls-and-cloud.tsx`
|
|
1022
|
+
|
|
1023
|
+
These are Ink React components. Each step manages its own state and calls `onComplete(data)` when the user finishes the step. Testing Ink components requires `ink-testing-library` which is not currently installed — these will be tested via the integration-level wizard test in Task 7.
|
|
1024
|
+
|
|
1025
|
+
- [ ] **Step 1: Create engagement-info step component**
|
|
1026
|
+
|
|
1027
|
+
```tsx
|
|
1028
|
+
// src/builder/steps/engagement-info.tsx
|
|
1029
|
+
import React, { useState } from "react";
|
|
1030
|
+
import { Box, Text, useInput } from "ink";
|
|
1031
|
+
|
|
1032
|
+
interface Props {
|
|
1033
|
+
initial?: { engagement_id?: string; client?: string; operator?: string };
|
|
1034
|
+
onComplete: (data: { engagement_id: string; client: string; operator: string }) => void;
|
|
1035
|
+
onBack?: () => void;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
type Field = "engagement_id" | "client" | "operator";
|
|
1039
|
+
|
|
1040
|
+
const FIELDS: { key: Field; label: string; placeholder: string }[] = [
|
|
1041
|
+
{ key: "engagement_id", label: "Engagement ID", placeholder: "ENG-2026-0001" },
|
|
1042
|
+
{ key: "client", label: "Client", placeholder: "Acme Corp" },
|
|
1043
|
+
{ key: "operator", label: "Operator", placeholder: "operator@integsec.com" },
|
|
1044
|
+
];
|
|
1045
|
+
|
|
1046
|
+
export function EngagementInfoStep({ initial, onComplete, onBack }: Props): React.ReactElement {
|
|
1047
|
+
const [fieldIndex, setFieldIndex] = useState(0);
|
|
1048
|
+
const [values, setValues] = useState({
|
|
1049
|
+
engagement_id: initial?.engagement_id ?? "",
|
|
1050
|
+
client: initial?.client ?? "",
|
|
1051
|
+
operator: initial?.operator ?? "",
|
|
1052
|
+
});
|
|
1053
|
+
const [input, setInput] = useState(values[FIELDS[0].key]);
|
|
1054
|
+
|
|
1055
|
+
useInput((ch, key) => {
|
|
1056
|
+
if (key.escape && onBack) {
|
|
1057
|
+
onBack();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (key.return) {
|
|
1062
|
+
const field = FIELDS[fieldIndex];
|
|
1063
|
+
const newValues = { ...values, [field.key]: input };
|
|
1064
|
+
setValues(newValues);
|
|
1065
|
+
|
|
1066
|
+
if (fieldIndex < FIELDS.length - 1) {
|
|
1067
|
+
const nextIndex = fieldIndex + 1;
|
|
1068
|
+
setFieldIndex(nextIndex);
|
|
1069
|
+
setInput(newValues[FIELDS[nextIndex].key]);
|
|
1070
|
+
} else {
|
|
1071
|
+
// Validate all fields are non-empty
|
|
1072
|
+
if (newValues.engagement_id && newValues.client && newValues.operator) {
|
|
1073
|
+
onComplete(newValues);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (key.backspace || key.delete) {
|
|
1080
|
+
setInput((prev) => prev.slice(0, -1));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
1085
|
+
setInput((prev) => prev + ch);
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
const field = FIELDS[fieldIndex];
|
|
1090
|
+
|
|
1091
|
+
return (
|
|
1092
|
+
<Box flexDirection="column" padding={1}>
|
|
1093
|
+
<Text bold color="cyan">Step 1: Engagement Info</Text>
|
|
1094
|
+
<Text dimColor>Press Enter to confirm each field. Esc to go back.</Text>
|
|
1095
|
+
<Box marginTop={1} />
|
|
1096
|
+
|
|
1097
|
+
{FIELDS.map((f, i) => (
|
|
1098
|
+
<Box key={f.key}>
|
|
1099
|
+
<Text dimColor={i !== fieldIndex}>
|
|
1100
|
+
{i < fieldIndex ? "✓" : i === fieldIndex ? "›" : " "} {f.label}:{" "}
|
|
1101
|
+
</Text>
|
|
1102
|
+
{i < fieldIndex ? (
|
|
1103
|
+
<Text color="green">{values[f.key]}</Text>
|
|
1104
|
+
) : i === fieldIndex ? (
|
|
1105
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1106
|
+
) : (
|
|
1107
|
+
<Text dimColor>{f.placeholder}</Text>
|
|
1108
|
+
)}
|
|
1109
|
+
</Box>
|
|
1110
|
+
))}
|
|
1111
|
+
</Box>
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
- [ ] **Step 2: Create engagement-window step component**
|
|
1117
|
+
|
|
1118
|
+
```tsx
|
|
1119
|
+
// src/builder/steps/engagement-window.tsx
|
|
1120
|
+
import React, { useState } from "react";
|
|
1121
|
+
import { Box, Text, useInput } from "ink";
|
|
1122
|
+
import { validateIsoDate, validateWindowSanity, type ValidationMessage } from "../smart-validator.js";
|
|
1123
|
+
|
|
1124
|
+
interface Props {
|
|
1125
|
+
initial?: { start?: string; end?: string };
|
|
1126
|
+
onComplete: (data: { start: string; end: string }) => void;
|
|
1127
|
+
onBack?: () => void;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
type Field = "start" | "end";
|
|
1131
|
+
|
|
1132
|
+
const FIELDS: { key: Field; label: string; placeholder: string }[] = [
|
|
1133
|
+
{ key: "start", label: "Start (ISO 8601)", placeholder: "2026-04-01T08:00:00Z" },
|
|
1134
|
+
{ key: "end", label: "End (ISO 8601)", placeholder: "2026-04-15T17:00:00Z" },
|
|
1135
|
+
];
|
|
1136
|
+
|
|
1137
|
+
export function EngagementWindowStep({ initial, onComplete, onBack }: Props): React.ReactElement {
|
|
1138
|
+
const [fieldIndex, setFieldIndex] = useState(0);
|
|
1139
|
+
const [values, setValues] = useState({
|
|
1140
|
+
start: initial?.start ?? "",
|
|
1141
|
+
end: initial?.end ?? "",
|
|
1142
|
+
});
|
|
1143
|
+
const [input, setInput] = useState(values[FIELDS[0].key]);
|
|
1144
|
+
const [messages, setMessages] = useState<ValidationMessage[]>([]);
|
|
1145
|
+
|
|
1146
|
+
useInput((ch, key) => {
|
|
1147
|
+
if (key.escape && onBack) {
|
|
1148
|
+
onBack();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (key.return) {
|
|
1153
|
+
const field = FIELDS[fieldIndex];
|
|
1154
|
+
|
|
1155
|
+
// Validate the current field
|
|
1156
|
+
const fieldMsgs = validateIsoDate(input);
|
|
1157
|
+
if (fieldMsgs.some((m) => m.level === "error")) {
|
|
1158
|
+
setMessages(fieldMsgs);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const newValues = { ...values, [field.key]: input };
|
|
1163
|
+
setValues(newValues);
|
|
1164
|
+
setMessages([]);
|
|
1165
|
+
|
|
1166
|
+
if (fieldIndex < FIELDS.length - 1) {
|
|
1167
|
+
const nextIndex = fieldIndex + 1;
|
|
1168
|
+
setFieldIndex(nextIndex);
|
|
1169
|
+
setInput(newValues[FIELDS[nextIndex].key]);
|
|
1170
|
+
} else {
|
|
1171
|
+
// Validate window sanity
|
|
1172
|
+
const windowMsgs = validateWindowSanity(newValues.start, newValues.end);
|
|
1173
|
+
const errors = windowMsgs.filter((m) => m.level === "error");
|
|
1174
|
+
if (errors.length > 0) {
|
|
1175
|
+
setMessages(windowMsgs);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
// Show warnings but proceed
|
|
1179
|
+
if (windowMsgs.length > 0) {
|
|
1180
|
+
setMessages(windowMsgs);
|
|
1181
|
+
}
|
|
1182
|
+
onComplete(newValues);
|
|
1183
|
+
}
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (key.backspace || key.delete) {
|
|
1188
|
+
setInput((prev) => prev.slice(0, -1));
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
1193
|
+
setInput((prev) => prev + ch);
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
return (
|
|
1198
|
+
<Box flexDirection="column" padding={1}>
|
|
1199
|
+
<Text bold color="cyan">Step 2: Engagement Window</Text>
|
|
1200
|
+
<Text dimColor>Enter start and end dates in ISO 8601 format. Esc to go back.</Text>
|
|
1201
|
+
<Box marginTop={1} />
|
|
1202
|
+
|
|
1203
|
+
{FIELDS.map((f, i) => (
|
|
1204
|
+
<Box key={f.key}>
|
|
1205
|
+
<Text dimColor={i !== fieldIndex}>
|
|
1206
|
+
{i < fieldIndex ? "✓" : i === fieldIndex ? "›" : " "} {f.label}:{" "}
|
|
1207
|
+
</Text>
|
|
1208
|
+
{i < fieldIndex ? (
|
|
1209
|
+
<Text color="green">{values[f.key]}</Text>
|
|
1210
|
+
) : i === fieldIndex ? (
|
|
1211
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1212
|
+
) : (
|
|
1213
|
+
<Text dimColor>{f.placeholder}</Text>
|
|
1214
|
+
)}
|
|
1215
|
+
</Box>
|
|
1216
|
+
))}
|
|
1217
|
+
|
|
1218
|
+
{messages.map((msg, i) => (
|
|
1219
|
+
<Box key={i} marginTop={i === 0 ? 1 : 0}>
|
|
1220
|
+
<Text color={msg.level === "error" ? "red" : msg.level === "warning" ? "yellow" : "blue"}>
|
|
1221
|
+
{msg.level === "error" ? "✗" : msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
|
|
1222
|
+
</Text>
|
|
1223
|
+
</Box>
|
|
1224
|
+
))}
|
|
1225
|
+
</Box>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
- [ ] **Step 3: Create domains step component**
|
|
1231
|
+
|
|
1232
|
+
```tsx
|
|
1233
|
+
// src/builder/steps/domains.tsx
|
|
1234
|
+
import React, { useState, useEffect } from "react";
|
|
1235
|
+
import { Box, Text, useInput } from "ink";
|
|
1236
|
+
import { validateDomain, checkWildcardApex, resolveDomainPreview, type ValidationMessage } from "../smart-validator.js";
|
|
1237
|
+
import { DnsResolver } from "../../dns-resolver.js";
|
|
1238
|
+
|
|
1239
|
+
interface Props {
|
|
1240
|
+
initial?: string[];
|
|
1241
|
+
authorizedIpRanges?: string[];
|
|
1242
|
+
onComplete: (domains: string[]) => void;
|
|
1243
|
+
onBack?: () => void;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
export function DomainsStep({ initial, authorizedIpRanges, onComplete, onBack }: Props): React.ReactElement {
|
|
1247
|
+
const [domains, setDomains] = useState<string[]>(initial ?? []);
|
|
1248
|
+
const [input, setInput] = useState("");
|
|
1249
|
+
const [messages, setMessages] = useState<ValidationMessage[]>([]);
|
|
1250
|
+
const [resolvedInfo, setResolvedInfo] = useState<string>("");
|
|
1251
|
+
const resolver = React.useRef(new DnsResolver(0)).current;
|
|
1252
|
+
|
|
1253
|
+
useInput((ch, key) => {
|
|
1254
|
+
if (key.escape) {
|
|
1255
|
+
if (input === "" && onBack) {
|
|
1256
|
+
onBack();
|
|
1257
|
+
} else {
|
|
1258
|
+
setInput("");
|
|
1259
|
+
setMessages([]);
|
|
1260
|
+
}
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// 'd' + ctrl = done
|
|
1265
|
+
if (ch === "d" && key.ctrl) {
|
|
1266
|
+
onComplete(domains);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (key.return) {
|
|
1271
|
+
if (input.trim() === "") {
|
|
1272
|
+
onComplete(domains);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Support comma-separated input
|
|
1277
|
+
const newDomains = input.split(",").map((d) => d.trim()).filter(Boolean);
|
|
1278
|
+
const allMsgs: ValidationMessage[] = [];
|
|
1279
|
+
const validDomains: string[] = [];
|
|
1280
|
+
|
|
1281
|
+
for (const domain of newDomains) {
|
|
1282
|
+
const domainMsgs = validateDomain(domain);
|
|
1283
|
+
if (domainMsgs.some((m) => m.level === "error")) {
|
|
1284
|
+
allMsgs.push(...domainMsgs);
|
|
1285
|
+
} else {
|
|
1286
|
+
validDomains.push(domain);
|
|
1287
|
+
const apexMsgs = checkWildcardApex(domain, [...domains, ...validDomains]);
|
|
1288
|
+
allMsgs.push(...apexMsgs);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (allMsgs.some((m) => m.level === "error")) {
|
|
1293
|
+
setMessages(allMsgs);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
setDomains((prev) => [...prev, ...validDomains]);
|
|
1298
|
+
setMessages(allMsgs); // Show info/warnings
|
|
1299
|
+
setInput("");
|
|
1300
|
+
|
|
1301
|
+
// Resolve DNS in background for display
|
|
1302
|
+
for (const domain of validDomains) {
|
|
1303
|
+
resolveDomainPreview(domain, resolver).then((result) => {
|
|
1304
|
+
if (result.ips.length > 0) {
|
|
1305
|
+
setResolvedInfo((prev) => prev + `${domain} → ${result.ips.join(", ")}\n`);
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (key.backspace || key.delete) {
|
|
1313
|
+
setInput((prev) => prev.slice(0, -1));
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
1318
|
+
setInput((prev) => prev + ch);
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
return (
|
|
1323
|
+
<Box flexDirection="column" padding={1}>
|
|
1324
|
+
<Text bold color="cyan">Step 3: Authorized Domains</Text>
|
|
1325
|
+
<Text dimColor>Enter domains (comma-separated OK). Empty enter or Ctrl+D when done. Esc to go back.</Text>
|
|
1326
|
+
<Box marginTop={1} />
|
|
1327
|
+
|
|
1328
|
+
{domains.map((d, i) => (
|
|
1329
|
+
<Text key={i} color="green"> ✓ {d}</Text>
|
|
1330
|
+
))}
|
|
1331
|
+
|
|
1332
|
+
<Box>
|
|
1333
|
+
<Text>› </Text>
|
|
1334
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1335
|
+
</Box>
|
|
1336
|
+
|
|
1337
|
+
{resolvedInfo && (
|
|
1338
|
+
<Box marginTop={1} flexDirection="column">
|
|
1339
|
+
<Text dimColor>DNS Preview:</Text>
|
|
1340
|
+
{resolvedInfo.trim().split("\n").map((line, i) => (
|
|
1341
|
+
<Text key={i} dimColor> {line}</Text>
|
|
1342
|
+
))}
|
|
1343
|
+
</Box>
|
|
1344
|
+
)}
|
|
1345
|
+
|
|
1346
|
+
{messages.map((msg, i) => (
|
|
1347
|
+
<Box key={i} marginTop={i === 0 ? 1 : 0}>
|
|
1348
|
+
<Text color={msg.level === "error" ? "red" : msg.level === "warning" ? "yellow" : "blue"}>
|
|
1349
|
+
{msg.level === "error" ? "✗" : msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
|
|
1350
|
+
</Text>
|
|
1351
|
+
</Box>
|
|
1352
|
+
))}
|
|
1353
|
+
|
|
1354
|
+
<Box marginTop={1}>
|
|
1355
|
+
<Text dimColor>{domains.length} domain(s) added</Text>
|
|
1356
|
+
</Box>
|
|
1357
|
+
</Box>
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
- [ ] **Step 4: Create ip-ranges step component**
|
|
1363
|
+
|
|
1364
|
+
```tsx
|
|
1365
|
+
// src/builder/steps/ip-ranges.tsx
|
|
1366
|
+
import React, { useState } from "react";
|
|
1367
|
+
import { Box, Text, useInput } from "ink";
|
|
1368
|
+
import { validateCidr, checkOverlap, type ValidationMessage } from "../smart-validator.js";
|
|
1369
|
+
|
|
1370
|
+
interface Props {
|
|
1371
|
+
initial?: string[];
|
|
1372
|
+
resolvedDomainIps?: string[];
|
|
1373
|
+
onComplete: (ranges: string[]) => void;
|
|
1374
|
+
onBack?: () => void;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
export function IpRangesStep({ initial, resolvedDomainIps, onComplete, onBack }: Props): React.ReactElement {
|
|
1378
|
+
const [ranges, setRanges] = useState<string[]>(initial ?? []);
|
|
1379
|
+
const [input, setInput] = useState("");
|
|
1380
|
+
const [messages, setMessages] = useState<ValidationMessage[]>([]);
|
|
1381
|
+
|
|
1382
|
+
useInput((ch, key) => {
|
|
1383
|
+
if (key.escape) {
|
|
1384
|
+
if (input === "" && onBack) {
|
|
1385
|
+
onBack();
|
|
1386
|
+
} else {
|
|
1387
|
+
setInput("");
|
|
1388
|
+
setMessages([]);
|
|
1389
|
+
}
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (ch === "d" && key.ctrl) {
|
|
1394
|
+
onComplete(ranges);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (key.return) {
|
|
1399
|
+
if (input.trim() === "") {
|
|
1400
|
+
onComplete(ranges);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const newRanges = input.split(",").map((r) => r.trim()).filter(Boolean);
|
|
1405
|
+
const allMsgs: ValidationMessage[] = [];
|
|
1406
|
+
const validRanges: string[] = [];
|
|
1407
|
+
|
|
1408
|
+
for (const range of newRanges) {
|
|
1409
|
+
const cidrMsgs = validateCidr(range);
|
|
1410
|
+
if (cidrMsgs.some((m) => m.level === "error")) {
|
|
1411
|
+
allMsgs.push(...cidrMsgs);
|
|
1412
|
+
} else {
|
|
1413
|
+
validRanges.push(range);
|
|
1414
|
+
allMsgs.push(...cidrMsgs); // Include warnings
|
|
1415
|
+
// Check overlap with domain-resolved IPs
|
|
1416
|
+
if (resolvedDomainIps) {
|
|
1417
|
+
allMsgs.push(...checkOverlap(resolvedDomainIps, [range]));
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (allMsgs.some((m) => m.level === "error")) {
|
|
1423
|
+
setMessages(allMsgs);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
setRanges((prev) => [...prev, ...validRanges]);
|
|
1428
|
+
setMessages(allMsgs);
|
|
1429
|
+
setInput("");
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (key.backspace || key.delete) {
|
|
1434
|
+
setInput((prev) => prev.slice(0, -1));
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
1439
|
+
setInput((prev) => prev + ch);
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
return (
|
|
1444
|
+
<Box flexDirection="column" padding={1}>
|
|
1445
|
+
<Text bold color="cyan">Step 4: Authorized IP Ranges</Text>
|
|
1446
|
+
<Text dimColor>Enter CIDR ranges (e.g. 10.10.10.0/24). Empty enter or Ctrl+D when done. Esc to go back.</Text>
|
|
1447
|
+
<Box marginTop={1} />
|
|
1448
|
+
|
|
1449
|
+
{ranges.map((r, i) => (
|
|
1450
|
+
<Text key={i} color="green"> ✓ {r}</Text>
|
|
1451
|
+
))}
|
|
1452
|
+
|
|
1453
|
+
<Box>
|
|
1454
|
+
<Text>› </Text>
|
|
1455
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1456
|
+
</Box>
|
|
1457
|
+
|
|
1458
|
+
{messages.map((msg, i) => (
|
|
1459
|
+
<Box key={i} marginTop={i === 0 ? 1 : 0}>
|
|
1460
|
+
<Text color={msg.level === "error" ? "red" : msg.level === "warning" ? "yellow" : "blue"}>
|
|
1461
|
+
{msg.level === "error" ? "✗" : msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
|
|
1462
|
+
</Text>
|
|
1463
|
+
</Box>
|
|
1464
|
+
))}
|
|
1465
|
+
|
|
1466
|
+
<Box marginTop={1}>
|
|
1467
|
+
<Text dimColor>{ranges.length} range(s) added</Text>
|
|
1468
|
+
</Box>
|
|
1469
|
+
</Box>
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
- [ ] **Step 5: Create urls-and-cloud step component**
|
|
1475
|
+
|
|
1476
|
+
```tsx
|
|
1477
|
+
// src/builder/steps/urls-and-cloud.tsx
|
|
1478
|
+
import React, { useState } from "react";
|
|
1479
|
+
import { Box, Text, useInput } from "ink";
|
|
1480
|
+
|
|
1481
|
+
interface Props {
|
|
1482
|
+
initial?: { urls?: string[]; cloud_accounts?: string[] };
|
|
1483
|
+
onComplete: (data: { urls: string[]; cloud_accounts: string[] }) => void;
|
|
1484
|
+
onBack?: () => void;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
type Section = "urls" | "cloud_accounts";
|
|
1488
|
+
|
|
1489
|
+
export function UrlsAndCloudStep({ initial, onComplete, onBack }: Props): React.ReactElement {
|
|
1490
|
+
const [section, setSection] = useState<Section>("urls");
|
|
1491
|
+
const [urls, setUrls] = useState<string[]>(initial?.urls ?? []);
|
|
1492
|
+
const [cloudAccounts, setCloudAccounts] = useState<string[]>(initial?.cloud_accounts ?? []);
|
|
1493
|
+
const [input, setInput] = useState("");
|
|
1494
|
+
|
|
1495
|
+
useInput((ch, key) => {
|
|
1496
|
+
if (key.escape) {
|
|
1497
|
+
if (input === "" && section === "urls" && onBack) {
|
|
1498
|
+
onBack();
|
|
1499
|
+
} else {
|
|
1500
|
+
setInput("");
|
|
1501
|
+
}
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (ch === "d" && key.ctrl) {
|
|
1506
|
+
onComplete({ urls, cloud_accounts: cloudAccounts });
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (key.return) {
|
|
1511
|
+
if (input.trim() === "") {
|
|
1512
|
+
if (section === "urls") {
|
|
1513
|
+
setSection("cloud_accounts");
|
|
1514
|
+
} else {
|
|
1515
|
+
onComplete({ urls, cloud_accounts: cloudAccounts });
|
|
1516
|
+
}
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const items = input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1521
|
+
if (section === "urls") {
|
|
1522
|
+
setUrls((prev) => [...prev, ...items]);
|
|
1523
|
+
} else {
|
|
1524
|
+
setCloudAccounts((prev) => [...prev, ...items]);
|
|
1525
|
+
}
|
|
1526
|
+
setInput("");
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (key.backspace || key.delete) {
|
|
1531
|
+
setInput((prev) => prev.slice(0, -1));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
1536
|
+
setInput((prev) => prev + ch);
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
return (
|
|
1541
|
+
<Box flexDirection="column" padding={1}>
|
|
1542
|
+
<Text bold color="cyan">Step 5: URLs & Cloud Accounts (optional)</Text>
|
|
1543
|
+
<Text dimColor>Enter items or press Enter to skip. Ctrl+D to finish step.</Text>
|
|
1544
|
+
<Box marginTop={1} />
|
|
1545
|
+
|
|
1546
|
+
<Text bold>{section === "urls" ? "› URLs:" : "URLs:"}</Text>
|
|
1547
|
+
{urls.map((u, i) => <Text key={i} color="green"> ✓ {u}</Text>)}
|
|
1548
|
+
{section === "urls" && (
|
|
1549
|
+
<Box>
|
|
1550
|
+
<Text> › </Text>
|
|
1551
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1552
|
+
</Box>
|
|
1553
|
+
)}
|
|
1554
|
+
|
|
1555
|
+
<Box marginTop={1} />
|
|
1556
|
+
<Text bold>{section === "cloud_accounts" ? "› Cloud Accounts:" : "Cloud Accounts:"}</Text>
|
|
1557
|
+
{cloudAccounts.map((c, i) => <Text key={i} color="green"> ✓ {c}</Text>)}
|
|
1558
|
+
{section === "cloud_accounts" && (
|
|
1559
|
+
<Box>
|
|
1560
|
+
<Text> › </Text>
|
|
1561
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1562
|
+
</Box>
|
|
1563
|
+
)}
|
|
1564
|
+
</Box>
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
- [ ] **Step 6: Commit**
|
|
1570
|
+
|
|
1571
|
+
```bash
|
|
1572
|
+
git add src/builder/steps/engagement-info.tsx src/builder/steps/engagement-window.tsx src/builder/steps/domains.tsx src/builder/steps/ip-ranges.tsx src/builder/steps/urls-and-cloud.tsx
|
|
1573
|
+
git commit -m "feat(builder): add wizard step components 1-5 (info, window, domains, IPs, URLs)"
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
---
|
|
1577
|
+
|
|
1578
|
+
### Task 6: Ink Wizard — Step Components (Steps 6–8)
|
|
1579
|
+
|
|
1580
|
+
**Files:**
|
|
1581
|
+
- Create: `src/builder/steps/exclusions.tsx`
|
|
1582
|
+
- Create: `src/builder/steps/techniques.tsx`
|
|
1583
|
+
- Create: `src/builder/steps/review.tsx`
|
|
1584
|
+
|
|
1585
|
+
- [ ] **Step 1: Create exclusions step component**
|
|
1586
|
+
|
|
1587
|
+
```tsx
|
|
1588
|
+
// src/builder/steps/exclusions.tsx
|
|
1589
|
+
import React, { useState } from "react";
|
|
1590
|
+
import { Box, Text, useInput } from "ink";
|
|
1591
|
+
import { checkOrphanedExclusion, type ValidationMessage } from "../smart-validator.js";
|
|
1592
|
+
|
|
1593
|
+
interface Props {
|
|
1594
|
+
initial?: string[];
|
|
1595
|
+
authorized: { ip_ranges: string[]; domains: string[] };
|
|
1596
|
+
onComplete: (excluded: string[]) => void;
|
|
1597
|
+
onBack?: () => void;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const SUGGESTED_EXCLUSIONS = [
|
|
1601
|
+
{ target: "169.254.169.254", reason: "AWS/Azure/GCP metadata endpoint" },
|
|
1602
|
+
{ target: "127.0.0.1", reason: "localhost" },
|
|
1603
|
+
];
|
|
1604
|
+
|
|
1605
|
+
export function ExclusionsStep({ initial, authorized, onComplete, onBack }: Props): React.ReactElement {
|
|
1606
|
+
const [excluded, setExcluded] = useState<string[]>(initial ?? []);
|
|
1607
|
+
const [input, setInput] = useState("");
|
|
1608
|
+
const [messages, setMessages] = useState<ValidationMessage[]>([]);
|
|
1609
|
+
const [showSuggestions, setShowSuggestions] = useState(true);
|
|
1610
|
+
|
|
1611
|
+
useInput((ch, key) => {
|
|
1612
|
+
if (key.escape) {
|
|
1613
|
+
if (input === "" && onBack) {
|
|
1614
|
+
onBack();
|
|
1615
|
+
} else {
|
|
1616
|
+
setInput("");
|
|
1617
|
+
setMessages([]);
|
|
1618
|
+
}
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (ch === "d" && key.ctrl) {
|
|
1623
|
+
onComplete(excluded);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (key.return) {
|
|
1628
|
+
if (input.trim() === "") {
|
|
1629
|
+
onComplete(excluded);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const items = input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1634
|
+
const allMsgs: ValidationMessage[] = [];
|
|
1635
|
+
|
|
1636
|
+
for (const item of items) {
|
|
1637
|
+
allMsgs.push(...checkOrphanedExclusion(item, authorized));
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
setExcluded((prev) => [...prev, ...items]);
|
|
1641
|
+
setMessages(allMsgs);
|
|
1642
|
+
setInput("");
|
|
1643
|
+
setShowSuggestions(false);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
if (key.backspace || key.delete) {
|
|
1648
|
+
setInput((prev) => prev.slice(0, -1));
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
1653
|
+
setInput((prev) => prev + ch);
|
|
1654
|
+
setShowSuggestions(false);
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
// Filter suggestions to exclude already-added items
|
|
1659
|
+
const activeSuggestions = SUGGESTED_EXCLUSIONS.filter(
|
|
1660
|
+
(s) => !excluded.includes(s.target)
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
return (
|
|
1664
|
+
<Box flexDirection="column" padding={1}>
|
|
1665
|
+
<Text bold color="cyan">Step 6: Excluded Targets</Text>
|
|
1666
|
+
<Text dimColor>Targets that are in scope but must NOT be tested. Empty enter or Ctrl+D when done.</Text>
|
|
1667
|
+
<Box marginTop={1} />
|
|
1668
|
+
|
|
1669
|
+
{showSuggestions && activeSuggestions.length > 0 && (
|
|
1670
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
1671
|
+
<Text dimColor>Suggested exclusions:</Text>
|
|
1672
|
+
{activeSuggestions.map((s, i) => (
|
|
1673
|
+
<Text key={i} dimColor> • {s.target} ({s.reason})</Text>
|
|
1674
|
+
))}
|
|
1675
|
+
</Box>
|
|
1676
|
+
)}
|
|
1677
|
+
|
|
1678
|
+
{excluded.map((e, i) => (
|
|
1679
|
+
<Text key={i} color="red"> ✗ {e}</Text>
|
|
1680
|
+
))}
|
|
1681
|
+
|
|
1682
|
+
<Box>
|
|
1683
|
+
<Text>› </Text>
|
|
1684
|
+
<Text>{input}<Text dimColor>│</Text></Text>
|
|
1685
|
+
</Box>
|
|
1686
|
+
|
|
1687
|
+
{messages.map((msg, i) => (
|
|
1688
|
+
<Box key={i} marginTop={i === 0 ? 1 : 0}>
|
|
1689
|
+
<Text color={msg.level === "warning" ? "yellow" : "blue"}>
|
|
1690
|
+
{msg.level === "warning" ? "⚠" : "ℹ"} {msg.message}
|
|
1691
|
+
</Text>
|
|
1692
|
+
</Box>
|
|
1693
|
+
))}
|
|
1694
|
+
|
|
1695
|
+
<Box marginTop={1}>
|
|
1696
|
+
<Text dimColor>{excluded.length} exclusion(s) added</Text>
|
|
1697
|
+
</Box>
|
|
1698
|
+
</Box>
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
```
|
|
1702
|
+
|
|
1703
|
+
- [ ] **Step 2: Create techniques step component**
|
|
1704
|
+
|
|
1705
|
+
```tsx
|
|
1706
|
+
// src/builder/steps/techniques.tsx
|
|
1707
|
+
import React, { useState } from "react";
|
|
1708
|
+
import { Box, Text, useInput } from "ink";
|
|
1709
|
+
|
|
1710
|
+
interface Props {
|
|
1711
|
+
initialAuthorized?: string[];
|
|
1712
|
+
initialExcluded?: string[];
|
|
1713
|
+
onComplete: (data: { authorized: string[]; excluded: string[] }) => void;
|
|
1714
|
+
onBack?: () => void;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const KNOWN_CATEGORIES = [
|
|
1718
|
+
{ name: "recon", description: "Reconnaissance and scanning", tools: "nmap, whois, dig, amass" },
|
|
1719
|
+
{ name: "web_app", description: "Web application testing", tools: "burp, zap, nikto, sqlmap" },
|
|
1720
|
+
{ name: "api_testing", description: "API security testing", tools: "postman, curl, httpie" },
|
|
1721
|
+
{ name: "ssl_tls", description: "SSL/TLS analysis", tools: "sslyze, testssl" },
|
|
1722
|
+
{ name: "dos", description: "Denial of service", tools: "hping3, slowloris, synflood" },
|
|
1723
|
+
{ name: "destructive", description: "Destructive operations", tools: "disk_wiper, *destroy*, *wipe*" },
|
|
1724
|
+
{ name: "social_engineering", description: "Social engineering", tools: "gophish, setoolkit, evilginx" },
|
|
1725
|
+
];
|
|
1726
|
+
|
|
1727
|
+
const DEFAULT_EXCLUDED = ["dos", "destructive", "social_engineering"];
|
|
1728
|
+
|
|
1729
|
+
type Phase = "authorized" | "excluded";
|
|
1730
|
+
|
|
1731
|
+
export function TechniquesStep({ initialAuthorized, initialExcluded, onComplete, onBack }: Props): React.ReactElement {
|
|
1732
|
+
const [phase, setPhase] = useState<Phase>("authorized");
|
|
1733
|
+
const [cursor, setCursor] = useState(0);
|
|
1734
|
+
const [authorized, setAuthorized] = useState<Set<string>>(
|
|
1735
|
+
new Set(initialAuthorized ?? ["recon", "web_app", "api_testing"])
|
|
1736
|
+
);
|
|
1737
|
+
const [excluded, setExcluded] = useState<Set<string>>(
|
|
1738
|
+
new Set(initialExcluded ?? DEFAULT_EXCLUDED)
|
|
1739
|
+
);
|
|
1740
|
+
|
|
1741
|
+
useInput((ch, key) => {
|
|
1742
|
+
if (key.escape && onBack) {
|
|
1743
|
+
if (phase === "excluded") {
|
|
1744
|
+
setPhase("authorized");
|
|
1745
|
+
setCursor(0);
|
|
1746
|
+
} else {
|
|
1747
|
+
onBack();
|
|
1748
|
+
}
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (key.upArrow) {
|
|
1753
|
+
setCursor((prev) => Math.max(0, prev - 1));
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if (key.downArrow) {
|
|
1758
|
+
setCursor((prev) => Math.min(KNOWN_CATEGORIES.length - 1, prev + 1));
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if (ch === " ") {
|
|
1763
|
+
const category = KNOWN_CATEGORIES[cursor].name;
|
|
1764
|
+
if (phase === "authorized") {
|
|
1765
|
+
setAuthorized((prev) => {
|
|
1766
|
+
const next = new Set(prev);
|
|
1767
|
+
next.has(category) ? next.delete(category) : next.add(category);
|
|
1768
|
+
return next;
|
|
1769
|
+
});
|
|
1770
|
+
} else {
|
|
1771
|
+
setExcluded((prev) => {
|
|
1772
|
+
const next = new Set(prev);
|
|
1773
|
+
next.has(category) ? next.delete(category) : next.add(category);
|
|
1774
|
+
return next;
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
if (key.return) {
|
|
1781
|
+
if (phase === "authorized") {
|
|
1782
|
+
setPhase("excluded");
|
|
1783
|
+
setCursor(0);
|
|
1784
|
+
} else {
|
|
1785
|
+
onComplete({
|
|
1786
|
+
authorized: Array.from(authorized),
|
|
1787
|
+
excluded: Array.from(excluded),
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
const currentSet = phase === "authorized" ? authorized : excluded;
|
|
1795
|
+
const title = phase === "authorized" ? "Authorized Techniques" : "Excluded Techniques";
|
|
1796
|
+
const color = phase === "authorized" ? "green" : "red";
|
|
1797
|
+
|
|
1798
|
+
return (
|
|
1799
|
+
<Box flexDirection="column" padding={1}>
|
|
1800
|
+
<Text bold color="cyan">Step 7: {title}</Text>
|
|
1801
|
+
<Text dimColor>Space to toggle, Enter to confirm, ↑/↓ to navigate.</Text>
|
|
1802
|
+
<Box marginTop={1} />
|
|
1803
|
+
|
|
1804
|
+
{KNOWN_CATEGORIES.map((cat, i) => (
|
|
1805
|
+
<Box key={cat.name}>
|
|
1806
|
+
<Text color={i === cursor ? "white" : undefined} dimColor={i !== cursor}>
|
|
1807
|
+
{i === cursor ? "›" : " "} [{currentSet.has(cat.name) ? "×" : " "}]{" "}
|
|
1808
|
+
</Text>
|
|
1809
|
+
<Text color={currentSet.has(cat.name) ? color : undefined} bold={i === cursor}>
|
|
1810
|
+
{cat.name}
|
|
1811
|
+
</Text>
|
|
1812
|
+
<Text dimColor> — {cat.description} ({cat.tools})</Text>
|
|
1813
|
+
</Box>
|
|
1814
|
+
))}
|
|
1815
|
+
|
|
1816
|
+
<Box marginTop={1}>
|
|
1817
|
+
<Text dimColor>
|
|
1818
|
+
{phase === "authorized"
|
|
1819
|
+
? `${authorized.size} authorized. Press Enter to configure excluded techniques.`
|
|
1820
|
+
: `${excluded.size} excluded. Press Enter to finish.`}
|
|
1821
|
+
</Text>
|
|
1822
|
+
</Box>
|
|
1823
|
+
</Box>
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
```
|
|
1827
|
+
|
|
1828
|
+
- [ ] **Step 3: Create review step component**
|
|
1829
|
+
|
|
1830
|
+
```tsx
|
|
1831
|
+
// src/builder/steps/review.tsx
|
|
1832
|
+
import React, { useState, useEffect } from "react";
|
|
1833
|
+
import { Box, Text, useInput } from "ink";
|
|
1834
|
+
import Spinner from "ink-spinner";
|
|
1835
|
+
import type { ScopeManifest } from "../../types.js";
|
|
1836
|
+
import { runDryRun, type DryRunResult } from "../dry-run.js";
|
|
1837
|
+
|
|
1838
|
+
interface Props {
|
|
1839
|
+
manifest: ScopeManifest;
|
|
1840
|
+
onConfirm: () => void;
|
|
1841
|
+
onBack?: () => void;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
export function ReviewStep({ manifest, onConfirm, onBack }: Props): React.ReactElement {
|
|
1845
|
+
const [dryRunResults, setDryRunResults] = useState<DryRunResult[] | null>(null);
|
|
1846
|
+
const [dryRunError, setDryRunError] = useState<string | null>(null);
|
|
1847
|
+
const [confirmed, setConfirmed] = useState(false);
|
|
1848
|
+
|
|
1849
|
+
useEffect(() => {
|
|
1850
|
+
runDryRun(manifest)
|
|
1851
|
+
.then(setDryRunResults)
|
|
1852
|
+
.catch((err) => setDryRunError(err.message));
|
|
1853
|
+
}, []);
|
|
1854
|
+
|
|
1855
|
+
useInput((ch, key) => {
|
|
1856
|
+
if (key.escape && onBack) {
|
|
1857
|
+
onBack();
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
if (key.return && dryRunResults !== null) {
|
|
1861
|
+
setConfirmed(true);
|
|
1862
|
+
onConfirm();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
const { authorized_targets: at, excluded_targets: et } = manifest;
|
|
1868
|
+
|
|
1869
|
+
return (
|
|
1870
|
+
<Box flexDirection="column" padding={1}>
|
|
1871
|
+
<Text bold color="cyan">Step 8: Review & Dry-Run</Text>
|
|
1872
|
+
<Box marginTop={1} />
|
|
1873
|
+
|
|
1874
|
+
{/* Manifest summary */}
|
|
1875
|
+
<Text bold>Engagement:</Text>
|
|
1876
|
+
<Text> ID: {manifest.engagement_id}</Text>
|
|
1877
|
+
<Text> Client: {manifest.client}</Text>
|
|
1878
|
+
<Text> Operator: {manifest.operator}</Text>
|
|
1879
|
+
<Text> Window: {manifest.engagement_window.start} → {manifest.engagement_window.end}</Text>
|
|
1880
|
+
|
|
1881
|
+
<Box marginTop={1} />
|
|
1882
|
+
<Text bold>Authorized Targets:</Text>
|
|
1883
|
+
<Text> Domains ({at.domains.length}): {at.domains.join(", ") || "none"}</Text>
|
|
1884
|
+
<Text> IP Ranges ({at.ip_ranges.length}): {at.ip_ranges.join(", ") || "none"}</Text>
|
|
1885
|
+
<Text> URLs ({at.urls.length}): {at.urls.join(", ") || "none"}</Text>
|
|
1886
|
+
<Text> Cloud ({at.cloud_accounts.length}): {at.cloud_accounts.join(", ") || "none"}</Text>
|
|
1887
|
+
|
|
1888
|
+
<Box marginTop={1} />
|
|
1889
|
+
<Text bold>Exclusions ({et.length}):</Text>
|
|
1890
|
+
{et.length > 0
|
|
1891
|
+
? et.map((e, i) => <Text key={i} color="red"> ✗ {e}</Text>)
|
|
1892
|
+
: <Text dimColor> none</Text>}
|
|
1893
|
+
|
|
1894
|
+
<Box marginTop={1} />
|
|
1895
|
+
<Text bold>Techniques:</Text>
|
|
1896
|
+
<Text color="green"> Authorized: {manifest.authorized_techniques.join(", ") || "none"}</Text>
|
|
1897
|
+
<Text color="red"> Excluded: {manifest.excluded_techniques.join(", ") || "none"}</Text>
|
|
1898
|
+
|
|
1899
|
+
{/* Dry-run results */}
|
|
1900
|
+
<Box marginTop={1} />
|
|
1901
|
+
<Text bold>Dry-Run Preview:</Text>
|
|
1902
|
+
{dryRunError && <Text color="red"> Error: {dryRunError}</Text>}
|
|
1903
|
+
{dryRunResults === null && !dryRunError && (
|
|
1904
|
+
<Text> <Spinner type="dots" /> Running dry-run...</Text>
|
|
1905
|
+
)}
|
|
1906
|
+
{dryRunResults !== null && (
|
|
1907
|
+
<Box flexDirection="column">
|
|
1908
|
+
{dryRunResults.map((r, i) => (
|
|
1909
|
+
<Text key={i} color={r.pass ? "green" : "red"}>
|
|
1910
|
+
{" "}{r.pass ? "✓" : "✗"} {r.tool} → {r.target}: {r.actual}
|
|
1911
|
+
{!r.pass ? ` (expected ${r.expected})` : ""}
|
|
1912
|
+
</Text>
|
|
1913
|
+
))}
|
|
1914
|
+
<Box marginTop={1} />
|
|
1915
|
+
{dryRunResults.some((r) => !r.pass) ? (
|
|
1916
|
+
<Text color="red" bold>⚠ Some dry-run checks did not match expectations. Review above.</Text>
|
|
1917
|
+
) : (
|
|
1918
|
+
<Text color="green" bold>✓ All dry-run checks passed.</Text>
|
|
1919
|
+
)}
|
|
1920
|
+
</Box>
|
|
1921
|
+
)}
|
|
1922
|
+
|
|
1923
|
+
<Box marginTop={1} />
|
|
1924
|
+
{dryRunResults !== null && !confirmed && (
|
|
1925
|
+
<Text dimColor>Press Enter to confirm and write manifest, Esc to go back.</Text>
|
|
1926
|
+
)}
|
|
1927
|
+
</Box>
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
- [ ] **Step 4: Commit**
|
|
1933
|
+
|
|
1934
|
+
```bash
|
|
1935
|
+
git add src/builder/steps/exclusions.tsx src/builder/steps/techniques.tsx src/builder/steps/review.tsx
|
|
1936
|
+
git commit -m "feat(builder): add wizard step components 6-8 (exclusions, techniques, review)"
|
|
1937
|
+
```
|
|
1938
|
+
|
|
1939
|
+
---
|
|
1940
|
+
|
|
1941
|
+
### Task 7: Wizard Orchestrator
|
|
1942
|
+
|
|
1943
|
+
**Files:**
|
|
1944
|
+
- Create: `src/builder/wizard.tsx`
|
|
1945
|
+
|
|
1946
|
+
- [ ] **Step 1: Create the wizard orchestrator**
|
|
1947
|
+
|
|
1948
|
+
```tsx
|
|
1949
|
+
// src/builder/wizard.tsx
|
|
1950
|
+
import React, { useState } from "react";
|
|
1951
|
+
import { Box, Text } from "ink";
|
|
1952
|
+
import type { ScopeManifest } from "../types.js";
|
|
1953
|
+
import { EngagementInfoStep } from "./steps/engagement-info.js";
|
|
1954
|
+
import { EngagementWindowStep } from "./steps/engagement-window.js";
|
|
1955
|
+
import { DomainsStep } from "./steps/domains.js";
|
|
1956
|
+
import { IpRangesStep } from "./steps/ip-ranges.js";
|
|
1957
|
+
import { UrlsAndCloudStep } from "./steps/urls-and-cloud.js";
|
|
1958
|
+
import { ExclusionsStep } from "./steps/exclusions.js";
|
|
1959
|
+
import { TechniquesStep } from "./steps/techniques.js";
|
|
1960
|
+
import { ReviewStep } from "./steps/review.js";
|
|
1961
|
+
|
|
1962
|
+
type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
1963
|
+
|
|
1964
|
+
interface Props {
|
|
1965
|
+
/** Pre-fill values (from --from flag or edit mode) */
|
|
1966
|
+
initial?: Partial<ScopeManifest>;
|
|
1967
|
+
/** Called when the wizard completes with a fully built manifest */
|
|
1968
|
+
onFinish: (manifest: ScopeManifest) => void;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
interface WizardState {
|
|
1972
|
+
engagement_id: string;
|
|
1973
|
+
client: string;
|
|
1974
|
+
operator: string;
|
|
1975
|
+
engagement_window: { start: string; end: string };
|
|
1976
|
+
domains: string[];
|
|
1977
|
+
ip_ranges: string[];
|
|
1978
|
+
urls: string[];
|
|
1979
|
+
cloud_accounts: string[];
|
|
1980
|
+
excluded_targets: string[];
|
|
1981
|
+
authorized_techniques: string[];
|
|
1982
|
+
excluded_techniques: string[];
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function buildManifest(state: WizardState): ScopeManifest {
|
|
1986
|
+
return {
|
|
1987
|
+
engagement_id: state.engagement_id,
|
|
1988
|
+
client: state.client,
|
|
1989
|
+
operator: state.operator,
|
|
1990
|
+
authorized_targets: {
|
|
1991
|
+
ip_ranges: state.ip_ranges,
|
|
1992
|
+
domains: state.domains,
|
|
1993
|
+
urls: state.urls,
|
|
1994
|
+
cloud_accounts: state.cloud_accounts,
|
|
1995
|
+
},
|
|
1996
|
+
excluded_targets: state.excluded_targets,
|
|
1997
|
+
authorized_techniques: state.authorized_techniques,
|
|
1998
|
+
excluded_techniques: state.excluded_techniques,
|
|
1999
|
+
engagement_window: state.engagement_window,
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
export function Wizard({ initial, onFinish }: Props): React.ReactElement {
|
|
2004
|
+
const [step, setStep] = useState<Step>(1);
|
|
2005
|
+
const [state, setState] = useState<WizardState>({
|
|
2006
|
+
engagement_id: initial?.engagement_id ?? "",
|
|
2007
|
+
client: initial?.client ?? "",
|
|
2008
|
+
operator: initial?.operator ?? "",
|
|
2009
|
+
engagement_window: initial?.engagement_window ?? { start: "", end: "" },
|
|
2010
|
+
domains: initial?.authorized_targets?.domains ?? [],
|
|
2011
|
+
ip_ranges: initial?.authorized_targets?.ip_ranges ?? [],
|
|
2012
|
+
urls: initial?.authorized_targets?.urls ?? [],
|
|
2013
|
+
cloud_accounts: initial?.authorized_targets?.cloud_accounts ?? [],
|
|
2014
|
+
excluded_targets: initial?.excluded_targets ?? [],
|
|
2015
|
+
authorized_techniques: initial?.authorized_techniques ?? [],
|
|
2016
|
+
excluded_techniques: initial?.excluded_techniques ?? [],
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
const goBack = () => setStep((s) => Math.max(1, s - 1) as Step);
|
|
2020
|
+
const goNext = () => setStep((s) => Math.min(8, s + 1) as Step);
|
|
2021
|
+
|
|
2022
|
+
return (
|
|
2023
|
+
<Box flexDirection="column">
|
|
2024
|
+
<Box marginBottom={1}>
|
|
2025
|
+
<Text dimColor>─── Scope Manifest Builder ─── Step {step}/8 ───</Text>
|
|
2026
|
+
</Box>
|
|
2027
|
+
|
|
2028
|
+
{step === 1 && (
|
|
2029
|
+
<EngagementInfoStep
|
|
2030
|
+
initial={{ engagement_id: state.engagement_id, client: state.client, operator: state.operator }}
|
|
2031
|
+
onComplete={(data) => {
|
|
2032
|
+
setState((s) => ({ ...s, ...data }));
|
|
2033
|
+
goNext();
|
|
2034
|
+
}}
|
|
2035
|
+
/>
|
|
2036
|
+
)}
|
|
2037
|
+
|
|
2038
|
+
{step === 2 && (
|
|
2039
|
+
<EngagementWindowStep
|
|
2040
|
+
initial={state.engagement_window}
|
|
2041
|
+
onComplete={(data) => {
|
|
2042
|
+
setState((s) => ({ ...s, engagement_window: data }));
|
|
2043
|
+
goNext();
|
|
2044
|
+
}}
|
|
2045
|
+
onBack={goBack}
|
|
2046
|
+
/>
|
|
2047
|
+
)}
|
|
2048
|
+
|
|
2049
|
+
{step === 3 && (
|
|
2050
|
+
<DomainsStep
|
|
2051
|
+
initial={state.domains}
|
|
2052
|
+
authorizedIpRanges={state.ip_ranges}
|
|
2053
|
+
onComplete={(domains) => {
|
|
2054
|
+
setState((s) => ({ ...s, domains }));
|
|
2055
|
+
goNext();
|
|
2056
|
+
}}
|
|
2057
|
+
onBack={goBack}
|
|
2058
|
+
/>
|
|
2059
|
+
)}
|
|
2060
|
+
|
|
2061
|
+
{step === 4 && (
|
|
2062
|
+
<IpRangesStep
|
|
2063
|
+
initial={state.ip_ranges}
|
|
2064
|
+
onComplete={(ranges) => {
|
|
2065
|
+
setState((s) => ({ ...s, ip_ranges: ranges }));
|
|
2066
|
+
goNext();
|
|
2067
|
+
}}
|
|
2068
|
+
onBack={goBack}
|
|
2069
|
+
/>
|
|
2070
|
+
)}
|
|
2071
|
+
|
|
2072
|
+
{step === 5 && (
|
|
2073
|
+
<UrlsAndCloudStep
|
|
2074
|
+
initial={{ urls: state.urls, cloud_accounts: state.cloud_accounts }}
|
|
2075
|
+
onComplete={(data) => {
|
|
2076
|
+
setState((s) => ({ ...s, urls: data.urls, cloud_accounts: data.cloud_accounts }));
|
|
2077
|
+
goNext();
|
|
2078
|
+
}}
|
|
2079
|
+
onBack={goBack}
|
|
2080
|
+
/>
|
|
2081
|
+
)}
|
|
2082
|
+
|
|
2083
|
+
{step === 6 && (
|
|
2084
|
+
<ExclusionsStep
|
|
2085
|
+
initial={state.excluded_targets}
|
|
2086
|
+
authorized={{ ip_ranges: state.ip_ranges, domains: state.domains }}
|
|
2087
|
+
onComplete={(excluded) => {
|
|
2088
|
+
setState((s) => ({ ...s, excluded_targets: excluded }));
|
|
2089
|
+
goNext();
|
|
2090
|
+
}}
|
|
2091
|
+
onBack={goBack}
|
|
2092
|
+
/>
|
|
2093
|
+
)}
|
|
2094
|
+
|
|
2095
|
+
{step === 7 && (
|
|
2096
|
+
<TechniquesStep
|
|
2097
|
+
initialAuthorized={state.authorized_techniques}
|
|
2098
|
+
initialExcluded={state.excluded_techniques}
|
|
2099
|
+
onComplete={(data) => {
|
|
2100
|
+
setState((s) => ({
|
|
2101
|
+
...s,
|
|
2102
|
+
authorized_techniques: data.authorized,
|
|
2103
|
+
excluded_techniques: data.excluded,
|
|
2104
|
+
}));
|
|
2105
|
+
goNext();
|
|
2106
|
+
}}
|
|
2107
|
+
onBack={goBack}
|
|
2108
|
+
/>
|
|
2109
|
+
)}
|
|
2110
|
+
|
|
2111
|
+
{step === 8 && (
|
|
2112
|
+
<ReviewStep
|
|
2113
|
+
manifest={buildManifest(state)}
|
|
2114
|
+
onConfirm={() => onFinish(buildManifest(state))}
|
|
2115
|
+
onBack={goBack}
|
|
2116
|
+
/>
|
|
2117
|
+
)}
|
|
2118
|
+
</Box>
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
```
|
|
2122
|
+
|
|
2123
|
+
- [ ] **Step 2: Commit**
|
|
2124
|
+
|
|
2125
|
+
```bash
|
|
2126
|
+
git add src/builder/wizard.tsx
|
|
2127
|
+
git commit -m "feat(builder): add wizard orchestrator with 8-step flow and back/forward navigation"
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
---
|
|
2131
|
+
|
|
2132
|
+
### Task 8: CLI Entry Point (`new` mode)
|
|
2133
|
+
|
|
2134
|
+
**Files:**
|
|
2135
|
+
- Create: `bin/integsec-agentic-pentest-manifest.ts`
|
|
2136
|
+
- Modify: `package.json`
|
|
2137
|
+
|
|
2138
|
+
- [ ] **Step 1: Create the CLI entry point**
|
|
2139
|
+
|
|
2140
|
+
```typescript
|
|
2141
|
+
// bin/integsec-agentic-pentest-manifest.ts
|
|
2142
|
+
#!/usr/bin/env node
|
|
2143
|
+
|
|
2144
|
+
import React from "react";
|
|
2145
|
+
import { render } from "ink";
|
|
2146
|
+
import fs from "fs";
|
|
2147
|
+
import path from "path";
|
|
2148
|
+
import { Wizard } from "../src/builder/wizard.js";
|
|
2149
|
+
import { writeManifest } from "../src/builder/manifest-writer.js";
|
|
2150
|
+
import { ScopeManifestSchema } from "../src/manifest-schema.js";
|
|
2151
|
+
import type { ScopeManifest } from "../src/types.js";
|
|
2152
|
+
|
|
2153
|
+
function parseArgs(): {
|
|
2154
|
+
mode: "new" | "edit" | "review" | "serve" | "help";
|
|
2155
|
+
fromTemplate?: string;
|
|
2156
|
+
targetFile?: string;
|
|
2157
|
+
section?: string;
|
|
2158
|
+
output?: string;
|
|
2159
|
+
} {
|
|
2160
|
+
const args = process.argv.slice(2);
|
|
2161
|
+
const mode = (args[0] ?? "new") as "new" | "edit" | "review" | "serve" | "help";
|
|
2162
|
+
|
|
2163
|
+
let fromTemplate: string | undefined;
|
|
2164
|
+
let targetFile: string | undefined;
|
|
2165
|
+
let section: string | undefined;
|
|
2166
|
+
let output: string | undefined;
|
|
2167
|
+
|
|
2168
|
+
// The second positional arg is the file for edit/review
|
|
2169
|
+
if ((mode === "edit" || mode === "review") && args[1] && !args[1].startsWith("--")) {
|
|
2170
|
+
targetFile = args[1];
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
for (let i = 1; i < args.length; i++) {
|
|
2174
|
+
if (args[i] === "--from" && args[i + 1]) {
|
|
2175
|
+
fromTemplate = args[i + 1];
|
|
2176
|
+
i++;
|
|
2177
|
+
} else if (args[i] === "--section" && args[i + 1]) {
|
|
2178
|
+
section = args[i + 1];
|
|
2179
|
+
i++;
|
|
2180
|
+
} else if ((args[i] === "--output" || args[i] === "-o") && args[i + 1]) {
|
|
2181
|
+
output = args[i + 1];
|
|
2182
|
+
i++;
|
|
2183
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
2184
|
+
return { mode: "help" };
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
return { mode, fromTemplate, targetFile, section, output };
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function printHelp(): void {
|
|
2192
|
+
console.log(`
|
|
2193
|
+
IntegSec Agentic Pentest MCP Proxy — Manifest Builder
|
|
2194
|
+
|
|
2195
|
+
Usage:
|
|
2196
|
+
integsec-agentic-pentest-manifest [mode] [options]
|
|
2197
|
+
|
|
2198
|
+
Modes:
|
|
2199
|
+
new Create a new scope manifest (default)
|
|
2200
|
+
edit <file> Edit an existing manifest
|
|
2201
|
+
review <file> Review a client-submitted manifest
|
|
2202
|
+
serve Start the client intake web server
|
|
2203
|
+
|
|
2204
|
+
Options:
|
|
2205
|
+
--from <template.json> Pre-fill wizard from a template (new mode)
|
|
2206
|
+
--section <name> Jump to a specific section (edit mode)
|
|
2207
|
+
-o, --output <file> Output file path (default: ./scope-manifest.json)
|
|
2208
|
+
-h, --help Show this help message
|
|
2209
|
+
|
|
2210
|
+
Examples:
|
|
2211
|
+
integsec-agentic-pentest-manifest new
|
|
2212
|
+
integsec-agentic-pentest-manifest new --from template.json -o scope.json
|
|
2213
|
+
integsec-agentic-pentest-manifest edit scope.json --section targets
|
|
2214
|
+
integsec-agentic-pentest-manifest review submission.pending.json
|
|
2215
|
+
integsec-agentic-pentest-manifest serve --template scope-template.json
|
|
2216
|
+
`);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function loadTemplate(filePath: string): Partial<ScopeManifest> {
|
|
2220
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
2221
|
+
const data = JSON.parse(content);
|
|
2222
|
+
// Partial parse — don't require all fields for a template
|
|
2223
|
+
return data as Partial<ScopeManifest>;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const config = parseArgs();
|
|
2227
|
+
|
|
2228
|
+
if (config.mode === "help") {
|
|
2229
|
+
printHelp();
|
|
2230
|
+
process.exit(0);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if (config.mode === "new") {
|
|
2234
|
+
const initial = config.fromTemplate ? loadTemplate(config.fromTemplate) : undefined;
|
|
2235
|
+
const outputPath = config.output ?? "./scope-manifest.json";
|
|
2236
|
+
|
|
2237
|
+
const { unmount } = render(
|
|
2238
|
+
<Wizard
|
|
2239
|
+
initial={initial}
|
|
2240
|
+
onFinish={(manifest) => {
|
|
2241
|
+
unmount();
|
|
2242
|
+
try {
|
|
2243
|
+
writeManifest(manifest, outputPath);
|
|
2244
|
+
console.log(`\n✓ Manifest written to ${outputPath}`);
|
|
2245
|
+
} catch (err: any) {
|
|
2246
|
+
console.error(`\n✗ ${err.message}`);
|
|
2247
|
+
process.exit(1);
|
|
2248
|
+
}
|
|
2249
|
+
}}
|
|
2250
|
+
/>
|
|
2251
|
+
);
|
|
2252
|
+
} else if (config.mode === "edit") {
|
|
2253
|
+
if (!config.targetFile) {
|
|
2254
|
+
console.error("Error: edit mode requires a file path. Usage: integsec-agentic-pentest-manifest edit <file>");
|
|
2255
|
+
process.exit(1);
|
|
2256
|
+
}
|
|
2257
|
+
const content = fs.readFileSync(config.targetFile, "utf-8");
|
|
2258
|
+
const manifest = ScopeManifestSchema.parse(JSON.parse(content));
|
|
2259
|
+
const outputPath = config.output ?? config.targetFile;
|
|
2260
|
+
|
|
2261
|
+
const { unmount } = render(
|
|
2262
|
+
<Wizard
|
|
2263
|
+
initial={manifest}
|
|
2264
|
+
onFinish={(updated) => {
|
|
2265
|
+
unmount();
|
|
2266
|
+
try {
|
|
2267
|
+
writeManifest(updated, outputPath, { force: true });
|
|
2268
|
+
console.log(`\n✓ Manifest updated at ${outputPath}`);
|
|
2269
|
+
} catch (err: any) {
|
|
2270
|
+
console.error(`\n✗ ${err.message}`);
|
|
2271
|
+
process.exit(1);
|
|
2272
|
+
}
|
|
2273
|
+
}}
|
|
2274
|
+
/>
|
|
2275
|
+
);
|
|
2276
|
+
} else if (config.mode === "review") {
|
|
2277
|
+
if (!config.targetFile) {
|
|
2278
|
+
console.error("Error: review mode requires a file path. Usage: integsec-agentic-pentest-manifest review <file>");
|
|
2279
|
+
process.exit(1);
|
|
2280
|
+
}
|
|
2281
|
+
// Review mode — Phase 2 (placeholder message)
|
|
2282
|
+
console.log("Review mode is coming in Phase 2. Use 'edit' to review and modify a manifest.");
|
|
2283
|
+
process.exit(0);
|
|
2284
|
+
} else if (config.mode === "serve") {
|
|
2285
|
+
// Serve mode — Phase 3 (placeholder message)
|
|
2286
|
+
console.log("Serve mode is coming in Phase 3. Web-based client intake form.");
|
|
2287
|
+
process.exit(0);
|
|
2288
|
+
}
|
|
2289
|
+
```
|
|
2290
|
+
|
|
2291
|
+
- [ ] **Step 2: Add bin entry to package.json**
|
|
2292
|
+
|
|
2293
|
+
Add to `package.json` `"bin"` section:
|
|
2294
|
+
|
|
2295
|
+
```json
|
|
2296
|
+
"integsec-agentic-pentest-manifest": "dist/bin/integsec-agentic-pentest-manifest.js"
|
|
2297
|
+
```
|
|
2298
|
+
|
|
2299
|
+
- [ ] **Step 3: Build and smoke test**
|
|
2300
|
+
|
|
2301
|
+
Run: `npx tsc && node dist/bin/integsec-agentic-pentest-manifest.js --help`
|
|
2302
|
+
Expected: Help text prints without errors
|
|
2303
|
+
|
|
2304
|
+
- [ ] **Step 4: Commit**
|
|
2305
|
+
|
|
2306
|
+
```bash
|
|
2307
|
+
git add bin/integsec-agentic-pentest-manifest.ts package.json
|
|
2308
|
+
git commit -m "feat(builder): add CLI entry point with new/edit modes and help"
|
|
2309
|
+
```
|
|
2310
|
+
|
|
2311
|
+
---
|
|
2312
|
+
|
|
2313
|
+
### Task 9: Run full test suite and fix issues
|
|
2314
|
+
|
|
2315
|
+
**Files:**
|
|
2316
|
+
- All files from Tasks 1-8
|
|
2317
|
+
|
|
2318
|
+
- [ ] **Step 1: Build the project**
|
|
2319
|
+
|
|
2320
|
+
Run: `npx tsc`
|
|
2321
|
+
Expected: No type errors
|
|
2322
|
+
|
|
2323
|
+
- [ ] **Step 2: Run all tests**
|
|
2324
|
+
|
|
2325
|
+
Run: `npx vitest run`
|
|
2326
|
+
Expected: All tests pass (existing + new)
|
|
2327
|
+
|
|
2328
|
+
- [ ] **Step 3: Fix any type errors or test failures**
|
|
2329
|
+
|
|
2330
|
+
Address any issues found in Step 1-2.
|
|
2331
|
+
|
|
2332
|
+
- [ ] **Step 4: Commit any fixes**
|
|
2333
|
+
|
|
2334
|
+
```bash
|
|
2335
|
+
git add -A
|
|
2336
|
+
git commit -m "fix(builder): resolve type errors and test failures from Phase 1"
|
|
2337
|
+
```
|
|
2338
|
+
|
|
2339
|
+
---
|
|
2340
|
+
|
|
2341
|
+
## Phase 2: `edit` and `review` modes
|
|
2342
|
+
|
|
2343
|
+
### Task 10: Review Mode CLI
|
|
2344
|
+
|
|
2345
|
+
**Files:**
|
|
2346
|
+
- Modify: `bin/integsec-agentic-pentest-manifest.ts`
|
|
2347
|
+
- Create: `src/builder/review-display.tsx`
|
|
2348
|
+
|
|
2349
|
+
- [ ] **Step 1: Create review display component**
|
|
2350
|
+
|
|
2351
|
+
```tsx
|
|
2352
|
+
// src/builder/review-display.tsx
|
|
2353
|
+
import React, { useState, useEffect } from "react";
|
|
2354
|
+
import { Box, Text, useInput } from "ink";
|
|
2355
|
+
import Spinner from "ink-spinner";
|
|
2356
|
+
import type { ScopeManifest } from "../types.js";
|
|
2357
|
+
import {
|
|
2358
|
+
validateCidr,
|
|
2359
|
+
validateDomain,
|
|
2360
|
+
validateWindowSanity,
|
|
2361
|
+
checkOrphanedExclusion,
|
|
2362
|
+
type ValidationMessage,
|
|
2363
|
+
} from "./smart-validator.js";
|
|
2364
|
+
import { runDryRun, type DryRunResult } from "./dry-run.js";
|
|
2365
|
+
import { writeManifest } from "./manifest-writer.js";
|
|
2366
|
+
|
|
2367
|
+
interface Props {
|
|
2368
|
+
manifest: ScopeManifest;
|
|
2369
|
+
sourcePath: string;
|
|
2370
|
+
onDone: () => void;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
type ReviewAction = "pending" | "approve" | "reject";
|
|
2374
|
+
|
|
2375
|
+
export function ReviewDisplay({ manifest, sourcePath, onDone }: Props): React.ReactElement {
|
|
2376
|
+
const [validationMsgs, setValidationMsgs] = useState<ValidationMessage[]>([]);
|
|
2377
|
+
const [dryRunResults, setDryRunResults] = useState<DryRunResult[] | null>(null);
|
|
2378
|
+
const [action, setAction] = useState<ReviewAction>("pending");
|
|
2379
|
+
|
|
2380
|
+
useEffect(() => {
|
|
2381
|
+
// Run all smart validations
|
|
2382
|
+
const msgs: ValidationMessage[] = [];
|
|
2383
|
+
|
|
2384
|
+
for (const cidr of manifest.authorized_targets.ip_ranges) {
|
|
2385
|
+
msgs.push(...validateCidr(cidr));
|
|
2386
|
+
}
|
|
2387
|
+
for (const domain of manifest.authorized_targets.domains) {
|
|
2388
|
+
msgs.push(...validateDomain(domain));
|
|
2389
|
+
}
|
|
2390
|
+
msgs.push(...validateWindowSanity(
|
|
2391
|
+
manifest.engagement_window.start,
|
|
2392
|
+
manifest.engagement_window.end
|
|
2393
|
+
));
|
|
2394
|
+
for (const excluded of manifest.excluded_targets) {
|
|
2395
|
+
msgs.push(...checkOrphanedExclusion(excluded, {
|
|
2396
|
+
ip_ranges: manifest.authorized_targets.ip_ranges,
|
|
2397
|
+
domains: manifest.authorized_targets.domains,
|
|
2398
|
+
}));
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
setValidationMsgs(msgs);
|
|
2402
|
+
|
|
2403
|
+
// Run dry-run
|
|
2404
|
+
runDryRun(manifest)
|
|
2405
|
+
.then(setDryRunResults)
|
|
2406
|
+
.catch(() => setDryRunResults([]));
|
|
2407
|
+
}, []);
|
|
2408
|
+
|
|
2409
|
+
useInput((ch, key) => {
|
|
2410
|
+
if (ch === "a" && action === "pending") {
|
|
2411
|
+
// Approve — write final manifest
|
|
2412
|
+
const outputPath = sourcePath.replace(".pending.json", ".json");
|
|
2413
|
+
try {
|
|
2414
|
+
writeManifest(manifest, outputPath, { force: true });
|
|
2415
|
+
setAction("approve");
|
|
2416
|
+
setTimeout(onDone, 500);
|
|
2417
|
+
} catch {
|
|
2418
|
+
// Error handled in display
|
|
2419
|
+
}
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
if (ch === "r" && action === "pending") {
|
|
2423
|
+
setAction("reject");
|
|
2424
|
+
setTimeout(onDone, 500);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (key.escape) {
|
|
2428
|
+
onDone();
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
const errors = validationMsgs.filter((m) => m.level === "error");
|
|
2434
|
+
const warnings = validationMsgs.filter((m) => m.level === "warning");
|
|
2435
|
+
|
|
2436
|
+
return (
|
|
2437
|
+
<Box flexDirection="column" padding={1}>
|
|
2438
|
+
<Text bold color="cyan">Manifest Review: {sourcePath}</Text>
|
|
2439
|
+
<Box marginTop={1} />
|
|
2440
|
+
|
|
2441
|
+
<Text bold>Engagement: {manifest.engagement_id} — {manifest.client}</Text>
|
|
2442
|
+
<Text>Operator: {manifest.operator}</Text>
|
|
2443
|
+
<Text>Window: {manifest.engagement_window.start} → {manifest.engagement_window.end}</Text>
|
|
2444
|
+
<Text>Domains: {manifest.authorized_targets.domains.join(", ") || "none"}</Text>
|
|
2445
|
+
<Text>IP Ranges: {manifest.authorized_targets.ip_ranges.join(", ") || "none"}</Text>
|
|
2446
|
+
<Text>Exclusions: {manifest.excluded_targets.join(", ") || "none"}</Text>
|
|
2447
|
+
|
|
2448
|
+
{/* Validation */}
|
|
2449
|
+
<Box marginTop={1} />
|
|
2450
|
+
<Text bold>Validation:</Text>
|
|
2451
|
+
{errors.length === 0 && warnings.length === 0 && (
|
|
2452
|
+
<Text color="green"> ✓ No issues found</Text>
|
|
2453
|
+
)}
|
|
2454
|
+
{errors.map((m, i) => <Text key={`e${i}`} color="red"> ✗ {m.message}</Text>)}
|
|
2455
|
+
{warnings.map((m, i) => <Text key={`w${i}`} color="yellow"> ⚠ {m.message}</Text>)}
|
|
2456
|
+
|
|
2457
|
+
{/* Dry-run */}
|
|
2458
|
+
<Box marginTop={1} />
|
|
2459
|
+
<Text bold>Dry-Run:</Text>
|
|
2460
|
+
{dryRunResults === null ? (
|
|
2461
|
+
<Text> <Spinner type="dots" /> Running...</Text>
|
|
2462
|
+
) : (
|
|
2463
|
+
dryRunResults.map((r, i) => (
|
|
2464
|
+
<Text key={i} color={r.pass ? "green" : "red"}>
|
|
2465
|
+
{" "}{r.pass ? "✓" : "✗"} {r.tool} → {r.target}: {r.actual}
|
|
2466
|
+
</Text>
|
|
2467
|
+
))
|
|
2468
|
+
)}
|
|
2469
|
+
|
|
2470
|
+
{/* Actions */}
|
|
2471
|
+
<Box marginTop={1} />
|
|
2472
|
+
{action === "pending" && (
|
|
2473
|
+
<Text dimColor>[a] Approve [r] Reject [Esc] Cancel</Text>
|
|
2474
|
+
)}
|
|
2475
|
+
{action === "approve" && (
|
|
2476
|
+
<Text color="green" bold>✓ Approved and saved.</Text>
|
|
2477
|
+
)}
|
|
2478
|
+
{action === "reject" && (
|
|
2479
|
+
<Text color="red" bold>✗ Rejected.</Text>
|
|
2480
|
+
)}
|
|
2481
|
+
</Box>
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
```
|
|
2485
|
+
|
|
2486
|
+
- [ ] **Step 2: Wire review mode into CLI**
|
|
2487
|
+
|
|
2488
|
+
In `bin/integsec-agentic-pentest-manifest.ts`, replace the review mode placeholder:
|
|
2489
|
+
|
|
2490
|
+
```typescript
|
|
2491
|
+
} else if (config.mode === "review") {
|
|
2492
|
+
if (!config.targetFile) {
|
|
2493
|
+
console.error("Error: review mode requires a file path. Usage: integsec-agentic-pentest-manifest review <file>");
|
|
2494
|
+
process.exit(1);
|
|
2495
|
+
}
|
|
2496
|
+
const content = fs.readFileSync(config.targetFile, "utf-8");
|
|
2497
|
+
const manifest = ScopeManifestSchema.parse(JSON.parse(content));
|
|
2498
|
+
|
|
2499
|
+
const { unmount } = render(
|
|
2500
|
+
<ReviewDisplay
|
|
2501
|
+
manifest={manifest}
|
|
2502
|
+
sourcePath={config.targetFile}
|
|
2503
|
+
onDone={() => {
|
|
2504
|
+
unmount();
|
|
2505
|
+
process.exit(0);
|
|
2506
|
+
}}
|
|
2507
|
+
/>
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
```
|
|
2511
|
+
|
|
2512
|
+
Add import at top:
|
|
2513
|
+
```typescript
|
|
2514
|
+
import { ReviewDisplay } from "../src/builder/review-display.js";
|
|
2515
|
+
```
|
|
2516
|
+
|
|
2517
|
+
- [ ] **Step 3: Build and test**
|
|
2518
|
+
|
|
2519
|
+
Run: `npx tsc`
|
|
2520
|
+
Expected: No errors
|
|
2521
|
+
|
|
2522
|
+
- [ ] **Step 4: Commit**
|
|
2523
|
+
|
|
2524
|
+
```bash
|
|
2525
|
+
git add src/builder/review-display.tsx bin/integsec-agentic-pentest-manifest.ts
|
|
2526
|
+
git commit -m "feat(builder): add review mode with smart validation and dry-run"
|
|
2527
|
+
```
|
|
2528
|
+
|
|
2529
|
+
---
|
|
2530
|
+
|
|
2531
|
+
## Phase 3: Web Server (`serve` mode)
|
|
2532
|
+
|
|
2533
|
+
### Task 11: Web Server Core
|
|
2534
|
+
|
|
2535
|
+
**Files:**
|
|
2536
|
+
- Create: `src/builder/web/server.ts`
|
|
2537
|
+
- Create: `src/builder/web/api.ts`
|
|
2538
|
+
- Test: `tests/unit/builder-web/server.test.ts`
|
|
2539
|
+
- Test: `tests/unit/builder-web/api.test.ts`
|
|
2540
|
+
|
|
2541
|
+
- [ ] **Step 1: Write failing tests for API handlers**
|
|
2542
|
+
|
|
2543
|
+
```typescript
|
|
2544
|
+
// tests/unit/builder-web/api.test.ts
|
|
2545
|
+
import { describe, it, expect } from "vitest";
|
|
2546
|
+
import {
|
|
2547
|
+
handleGetTemplate,
|
|
2548
|
+
handlePostSubmit,
|
|
2549
|
+
} from "../../../src/builder/web/api.js";
|
|
2550
|
+
import type { ScopeManifest } from "../../../src/types.js";
|
|
2551
|
+
import fs from "fs";
|
|
2552
|
+
import path from "path";
|
|
2553
|
+
|
|
2554
|
+
const template: Partial<ScopeManifest> = {
|
|
2555
|
+
engagement_id: "ENG-2026-0001",
|
|
2556
|
+
client: "Test Corp",
|
|
2557
|
+
operator: "test@test.com",
|
|
2558
|
+
engagement_window: { start: "2026-04-01T00:00:00Z", end: "2026-04-15T00:00:00Z" },
|
|
2559
|
+
excluded_techniques: ["dos", "destructive"],
|
|
2560
|
+
};
|
|
2561
|
+
|
|
2562
|
+
describe("API Handlers", () => {
|
|
2563
|
+
describe("handleGetTemplate", () => {
|
|
2564
|
+
it("returns the template as JSON", () => {
|
|
2565
|
+
const result = handleGetTemplate(template);
|
|
2566
|
+
expect(result.status).toBe(200);
|
|
2567
|
+
expect(JSON.parse(result.body)).toEqual(template);
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
it("returns empty object when no template", () => {
|
|
2571
|
+
const result = handleGetTemplate(undefined);
|
|
2572
|
+
expect(result.status).toBe(200);
|
|
2573
|
+
expect(JSON.parse(result.body)).toEqual({});
|
|
2574
|
+
});
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
describe("handlePostSubmit", () => {
|
|
2578
|
+
const outputDir = path.join(process.cwd(), "test-output-api");
|
|
2579
|
+
|
|
2580
|
+
afterEach(() => {
|
|
2581
|
+
if (fs.existsSync(outputDir)) {
|
|
2582
|
+
fs.rmSync(outputDir, { recursive: true });
|
|
2583
|
+
}
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
it("validates and saves a valid submission", () => {
|
|
2587
|
+
const submission: ScopeManifest = {
|
|
2588
|
+
engagement_id: "ENG-2026-0001",
|
|
2589
|
+
client: "Test Corp",
|
|
2590
|
+
operator: "test@test.com",
|
|
2591
|
+
authorized_targets: { ip_ranges: ["10.0.0.0/24"], domains: ["*.test.com"], urls: [], cloud_accounts: [] },
|
|
2592
|
+
excluded_targets: [],
|
|
2593
|
+
authorized_techniques: ["recon"],
|
|
2594
|
+
excluded_techniques: ["dos"],
|
|
2595
|
+
engagement_window: { start: "2026-04-01T00:00:00Z", end: "2026-04-15T00:00:00Z" },
|
|
2596
|
+
};
|
|
2597
|
+
|
|
2598
|
+
const result = handlePostSubmit(JSON.stringify(submission), outputDir);
|
|
2599
|
+
expect(result.status).toBe(200);
|
|
2600
|
+
expect(JSON.parse(result.body).success).toBe(true);
|
|
2601
|
+
|
|
2602
|
+
// Check file was written
|
|
2603
|
+
const files = fs.readdirSync(outputDir).filter((f) => f.endsWith(".pending.json"));
|
|
2604
|
+
expect(files).toHaveLength(1);
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
it("rejects invalid JSON", () => {
|
|
2608
|
+
const result = handlePostSubmit("not json", outputDir);
|
|
2609
|
+
expect(result.status).toBe(400);
|
|
2610
|
+
});
|
|
2611
|
+
|
|
2612
|
+
it("rejects manifest that fails schema validation", () => {
|
|
2613
|
+
const result = handlePostSubmit(JSON.stringify({ engagement_id: "" }), outputDir);
|
|
2614
|
+
expect(result.status).toBe(400);
|
|
2615
|
+
});
|
|
2616
|
+
});
|
|
2617
|
+
});
|
|
2618
|
+
```
|
|
2619
|
+
|
|
2620
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
2621
|
+
|
|
2622
|
+
Run: `npx vitest run tests/unit/builder-web/api.test.ts`
|
|
2623
|
+
Expected: FAIL — module not found
|
|
2624
|
+
|
|
2625
|
+
- [ ] **Step 3: Implement API handlers**
|
|
2626
|
+
|
|
2627
|
+
```typescript
|
|
2628
|
+
// src/builder/web/api.ts
|
|
2629
|
+
|
|
2630
|
+
import fs from "fs";
|
|
2631
|
+
import path from "path";
|
|
2632
|
+
import { ScopeManifestSchema } from "../../manifest-schema.js";
|
|
2633
|
+
import type { ScopeManifest } from "../../types.js";
|
|
2634
|
+
|
|
2635
|
+
export interface ApiResponse {
|
|
2636
|
+
status: number;
|
|
2637
|
+
body: string;
|
|
2638
|
+
contentType: string;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
export function handleGetTemplate(template: Partial<ScopeManifest> | undefined): ApiResponse {
|
|
2642
|
+
return {
|
|
2643
|
+
status: 200,
|
|
2644
|
+
body: JSON.stringify(template ?? {}),
|
|
2645
|
+
contentType: "application/json",
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
export function handlePostSubmit(rawBody: string, outputDir: string): ApiResponse {
|
|
2650
|
+
let data: unknown;
|
|
2651
|
+
try {
|
|
2652
|
+
data = JSON.parse(rawBody);
|
|
2653
|
+
} catch {
|
|
2654
|
+
return {
|
|
2655
|
+
status: 400,
|
|
2656
|
+
body: JSON.stringify({ success: false, error: "Invalid JSON" }),
|
|
2657
|
+
contentType: "application/json",
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
const parseResult = ScopeManifestSchema.safeParse(data);
|
|
2662
|
+
if (!parseResult.success) {
|
|
2663
|
+
return {
|
|
2664
|
+
status: 400,
|
|
2665
|
+
body: JSON.stringify({
|
|
2666
|
+
success: false,
|
|
2667
|
+
error: "Schema validation failed",
|
|
2668
|
+
details: parseResult.error.issues.map((i) => i.message),
|
|
2669
|
+
}),
|
|
2670
|
+
contentType: "application/json",
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// Write to outputDir as a .pending.json file
|
|
2675
|
+
if (!fs.existsSync(outputDir)) {
|
|
2676
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const filename = `${parseResult.data.engagement_id}-${Date.now()}.pending.json`;
|
|
2680
|
+
const filePath = path.join(outputDir, filename);
|
|
2681
|
+
fs.writeFileSync(filePath, JSON.stringify(parseResult.data, null, 2) + "\n", "utf-8");
|
|
2682
|
+
|
|
2683
|
+
return {
|
|
2684
|
+
status: 200,
|
|
2685
|
+
body: JSON.stringify({ success: true, file: filename }),
|
|
2686
|
+
contentType: "application/json",
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
```
|
|
2690
|
+
|
|
2691
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
2692
|
+
|
|
2693
|
+
Run: `npx vitest run tests/unit/builder-web/api.test.ts`
|
|
2694
|
+
Expected: All tests PASS
|
|
2695
|
+
|
|
2696
|
+
- [ ] **Step 5: Implement web server with TLS enforcement**
|
|
2697
|
+
|
|
2698
|
+
```typescript
|
|
2699
|
+
// src/builder/web/server.ts
|
|
2700
|
+
|
|
2701
|
+
import http from "http";
|
|
2702
|
+
import https from "https";
|
|
2703
|
+
import fs from "fs";
|
|
2704
|
+
import path from "path";
|
|
2705
|
+
import { fileURLToPath } from "url";
|
|
2706
|
+
import type { ScopeManifest } from "../../types.js";
|
|
2707
|
+
import { handleGetTemplate, handlePostSubmit } from "./api.js";
|
|
2708
|
+
|
|
2709
|
+
export interface ServeOptions {
|
|
2710
|
+
port: number;
|
|
2711
|
+
host: string;
|
|
2712
|
+
template?: Partial<ScopeManifest>;
|
|
2713
|
+
outputDir: string;
|
|
2714
|
+
tlsCert?: string;
|
|
2715
|
+
tlsKey?: string;
|
|
2716
|
+
timeoutMinutes: number;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
export function startServer(options: ServeOptions): http.Server | https.Server {
|
|
2720
|
+
const { port, host, template, outputDir, tlsCert, tlsKey, timeoutMinutes } = options;
|
|
2721
|
+
|
|
2722
|
+
// TLS enforcement: non-localhost requires TLS
|
|
2723
|
+
const isLocalhost = host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
2724
|
+
if (!isLocalhost && (!tlsCert || !tlsKey)) {
|
|
2725
|
+
console.error(
|
|
2726
|
+
"Refusing to serve client intake form over plaintext on a non-localhost interface. " +
|
|
2727
|
+
"Provide --tls-cert and --tls-key."
|
|
2728
|
+
);
|
|
2729
|
+
process.exit(1);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// Load the client form HTML
|
|
2733
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2734
|
+
const formPath = path.join(thisDir, "client-form.html");
|
|
2735
|
+
const formHtml = fs.existsSync(formPath) ? fs.readFileSync(formPath, "utf-8") : "<h1>Form not found</h1>";
|
|
2736
|
+
|
|
2737
|
+
const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
2738
|
+
// CORS headers for local development
|
|
2739
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
2740
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
2741
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
2742
|
+
|
|
2743
|
+
if (req.method === "OPTIONS") {
|
|
2744
|
+
res.writeHead(204);
|
|
2745
|
+
res.end();
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "")) {
|
|
2750
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2751
|
+
res.end(formHtml);
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
if (req.method === "GET" && req.url === "/template") {
|
|
2756
|
+
const result = handleGetTemplate(template);
|
|
2757
|
+
res.writeHead(result.status, { "Content-Type": result.contentType });
|
|
2758
|
+
res.end(result.body);
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
if (req.method === "POST" && req.url === "/submit") {
|
|
2763
|
+
let body = "";
|
|
2764
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
2765
|
+
req.on("end", () => {
|
|
2766
|
+
const result = handlePostSubmit(body, outputDir);
|
|
2767
|
+
res.writeHead(result.status, { "Content-Type": result.contentType });
|
|
2768
|
+
res.end(result.body);
|
|
2769
|
+
|
|
2770
|
+
if (result.status === 200) {
|
|
2771
|
+
console.log(`\n✓ Submission received and saved to ${outputDir}`);
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2778
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
2779
|
+
};
|
|
2780
|
+
|
|
2781
|
+
let server: http.Server | https.Server;
|
|
2782
|
+
|
|
2783
|
+
if (tlsCert && tlsKey) {
|
|
2784
|
+
const cert = fs.readFileSync(tlsCert, "utf-8");
|
|
2785
|
+
const key = fs.readFileSync(tlsKey, "utf-8");
|
|
2786
|
+
server = https.createServer({ cert, key }, handler);
|
|
2787
|
+
} else {
|
|
2788
|
+
server = http.createServer(handler);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// Auto-shutdown timer
|
|
2792
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
2793
|
+
const resetTimeout = () => {
|
|
2794
|
+
if (timeout) clearTimeout(timeout);
|
|
2795
|
+
timeout = setTimeout(() => {
|
|
2796
|
+
console.log(`\nServer auto-shutdown after ${timeoutMinutes} minutes of inactivity.`);
|
|
2797
|
+
server.close();
|
|
2798
|
+
process.exit(0);
|
|
2799
|
+
}, timeoutMinutes * 60_000);
|
|
2800
|
+
};
|
|
2801
|
+
|
|
2802
|
+
server.on("request", resetTimeout);
|
|
2803
|
+
|
|
2804
|
+
server.listen(port, host, () => {
|
|
2805
|
+
const protocol = tlsCert ? "https" : "http";
|
|
2806
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
2807
|
+
console.log(`\n🔒 Scope Manifest — Client Intake Form`);
|
|
2808
|
+
console.log(` ${protocol}://${displayHost}:${port}`);
|
|
2809
|
+
console.log(` Output: ${outputDir}`);
|
|
2810
|
+
console.log(` Auto-shutdown: ${timeoutMinutes} minutes\n`);
|
|
2811
|
+
resetTimeout();
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
return server;
|
|
2815
|
+
}
|
|
2816
|
+
```
|
|
2817
|
+
|
|
2818
|
+
- [ ] **Step 6: Write failing test for server TLS enforcement**
|
|
2819
|
+
|
|
2820
|
+
```typescript
|
|
2821
|
+
// tests/unit/builder-web/server.test.ts
|
|
2822
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2823
|
+
|
|
2824
|
+
describe("Server TLS enforcement", () => {
|
|
2825
|
+
it("exits with error when non-localhost host lacks TLS", async () => {
|
|
2826
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });
|
|
2827
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
2828
|
+
|
|
2829
|
+
const { startServer } = await import("../../../src/builder/web/server.js");
|
|
2830
|
+
|
|
2831
|
+
expect(() => startServer({
|
|
2832
|
+
port: 0,
|
|
2833
|
+
host: "0.0.0.0",
|
|
2834
|
+
outputDir: "/tmp/test",
|
|
2835
|
+
timeoutMinutes: 60,
|
|
2836
|
+
})).toThrow("exit");
|
|
2837
|
+
|
|
2838
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
2839
|
+
expect.stringContaining("Refusing to serve")
|
|
2840
|
+
);
|
|
2841
|
+
|
|
2842
|
+
exitSpy.mockRestore();
|
|
2843
|
+
errorSpy.mockRestore();
|
|
2844
|
+
});
|
|
2845
|
+
});
|
|
2846
|
+
```
|
|
2847
|
+
|
|
2848
|
+
- [ ] **Step 7: Run tests to verify they pass**
|
|
2849
|
+
|
|
2850
|
+
Run: `npx vitest run tests/unit/builder-web/`
|
|
2851
|
+
Expected: All tests PASS
|
|
2852
|
+
|
|
2853
|
+
- [ ] **Step 8: Commit**
|
|
2854
|
+
|
|
2855
|
+
```bash
|
|
2856
|
+
git add src/builder/web/server.ts src/builder/web/api.ts tests/unit/builder-web/
|
|
2857
|
+
git commit -m "feat(builder): add web server with TLS enforcement and API handlers"
|
|
2858
|
+
```
|
|
2859
|
+
|
|
2860
|
+
---
|
|
2861
|
+
|
|
2862
|
+
### Task 12: Client Intake Form HTML
|
|
2863
|
+
|
|
2864
|
+
**Files:**
|
|
2865
|
+
- Create: `src/builder/web/client-form.html`
|
|
2866
|
+
|
|
2867
|
+
- [ ] **Step 1: Create the client intake form**
|
|
2868
|
+
|
|
2869
|
+
```html
|
|
2870
|
+
<!-- src/builder/web/client-form.html -->
|
|
2871
|
+
<!DOCTYPE html>
|
|
2872
|
+
<html lang="en">
|
|
2873
|
+
<head>
|
|
2874
|
+
<meta charset="utf-8">
|
|
2875
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2876
|
+
<title>IntegSec — Scope Definition</title>
|
|
2877
|
+
<style>
|
|
2878
|
+
:root {
|
|
2879
|
+
--bg: #0f1117;
|
|
2880
|
+
--surface: #1a1d27;
|
|
2881
|
+
--border: #2a2d3a;
|
|
2882
|
+
--text: #e1e4ed;
|
|
2883
|
+
--text-dim: #8b8fa3;
|
|
2884
|
+
--accent: #6366f1;
|
|
2885
|
+
--accent-hover: #818cf8;
|
|
2886
|
+
--success: #10b981;
|
|
2887
|
+
--error: #ef4444;
|
|
2888
|
+
--warning: #f59e0b;
|
|
2889
|
+
}
|
|
2890
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2891
|
+
body {
|
|
2892
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
2893
|
+
background: var(--bg);
|
|
2894
|
+
color: var(--text);
|
|
2895
|
+
line-height: 1.6;
|
|
2896
|
+
padding: 2rem;
|
|
2897
|
+
max-width: 800px;
|
|
2898
|
+
margin: 0 auto;
|
|
2899
|
+
}
|
|
2900
|
+
h1 {
|
|
2901
|
+
font-size: 1.5rem;
|
|
2902
|
+
margin-bottom: 0.25rem;
|
|
2903
|
+
}
|
|
2904
|
+
.subtitle {
|
|
2905
|
+
color: var(--text-dim);
|
|
2906
|
+
margin-bottom: 2rem;
|
|
2907
|
+
}
|
|
2908
|
+
.section {
|
|
2909
|
+
background: var(--surface);
|
|
2910
|
+
border: 1px solid var(--border);
|
|
2911
|
+
border-radius: 8px;
|
|
2912
|
+
padding: 1.5rem;
|
|
2913
|
+
margin-bottom: 1.5rem;
|
|
2914
|
+
}
|
|
2915
|
+
.section h2 {
|
|
2916
|
+
font-size: 1.1rem;
|
|
2917
|
+
margin-bottom: 1rem;
|
|
2918
|
+
display: flex;
|
|
2919
|
+
align-items: center;
|
|
2920
|
+
gap: 0.5rem;
|
|
2921
|
+
}
|
|
2922
|
+
label {
|
|
2923
|
+
display: block;
|
|
2924
|
+
font-size: 0.875rem;
|
|
2925
|
+
color: var(--text-dim);
|
|
2926
|
+
margin-bottom: 0.25rem;
|
|
2927
|
+
}
|
|
2928
|
+
.tooltip {
|
|
2929
|
+
font-size: 0.75rem;
|
|
2930
|
+
color: var(--text-dim);
|
|
2931
|
+
margin-bottom: 0.5rem;
|
|
2932
|
+
font-style: italic;
|
|
2933
|
+
}
|
|
2934
|
+
input, textarea {
|
|
2935
|
+
width: 100%;
|
|
2936
|
+
padding: 0.5rem 0.75rem;
|
|
2937
|
+
background: var(--bg);
|
|
2938
|
+
border: 1px solid var(--border);
|
|
2939
|
+
border-radius: 4px;
|
|
2940
|
+
color: var(--text);
|
|
2941
|
+
font-family: "SF Mono", "Fira Code", monospace;
|
|
2942
|
+
font-size: 0.875rem;
|
|
2943
|
+
margin-bottom: 1rem;
|
|
2944
|
+
}
|
|
2945
|
+
input:focus, textarea:focus {
|
|
2946
|
+
outline: none;
|
|
2947
|
+
border-color: var(--accent);
|
|
2948
|
+
}
|
|
2949
|
+
input:disabled {
|
|
2950
|
+
opacity: 0.5;
|
|
2951
|
+
cursor: not-allowed;
|
|
2952
|
+
}
|
|
2953
|
+
input.valid { border-color: var(--success); }
|
|
2954
|
+
input.invalid { border-color: var(--error); }
|
|
2955
|
+
.field-msg {
|
|
2956
|
+
font-size: 0.75rem;
|
|
2957
|
+
margin-top: -0.75rem;
|
|
2958
|
+
margin-bottom: 0.75rem;
|
|
2959
|
+
}
|
|
2960
|
+
.field-msg.error { color: var(--error); }
|
|
2961
|
+
.field-msg.warning { color: var(--warning); }
|
|
2962
|
+
.field-msg.info { color: var(--accent); }
|
|
2963
|
+
textarea { min-height: 80px; resize: vertical; }
|
|
2964
|
+
button {
|
|
2965
|
+
background: var(--accent);
|
|
2966
|
+
color: white;
|
|
2967
|
+
border: none;
|
|
2968
|
+
padding: 0.75rem 2rem;
|
|
2969
|
+
border-radius: 6px;
|
|
2970
|
+
font-size: 1rem;
|
|
2971
|
+
cursor: pointer;
|
|
2972
|
+
display: block;
|
|
2973
|
+
width: 100%;
|
|
2974
|
+
margin-top: 1rem;
|
|
2975
|
+
}
|
|
2976
|
+
button:hover { background: var(--accent-hover); }
|
|
2977
|
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
2978
|
+
.result {
|
|
2979
|
+
padding: 1rem;
|
|
2980
|
+
border-radius: 6px;
|
|
2981
|
+
margin-top: 1rem;
|
|
2982
|
+
text-align: center;
|
|
2983
|
+
}
|
|
2984
|
+
.result.success { background: rgba(16, 185, 129, 0.1); border: 1px solid var(--success); color: var(--success); }
|
|
2985
|
+
.result.error { background: rgba(239, 68, 68, 0.1); border: 1px solid var(--error); color: var(--error); }
|
|
2986
|
+
.locked-badge {
|
|
2987
|
+
font-size: 0.65rem;
|
|
2988
|
+
background: var(--border);
|
|
2989
|
+
padding: 0.15rem 0.4rem;
|
|
2990
|
+
border-radius: 3px;
|
|
2991
|
+
color: var(--text-dim);
|
|
2992
|
+
vertical-align: middle;
|
|
2993
|
+
}
|
|
2994
|
+
.brand {
|
|
2995
|
+
display: flex;
|
|
2996
|
+
align-items: center;
|
|
2997
|
+
gap: 0.5rem;
|
|
2998
|
+
margin-bottom: 1.5rem;
|
|
2999
|
+
padding-bottom: 1rem;
|
|
3000
|
+
border-bottom: 1px solid var(--border);
|
|
3001
|
+
}
|
|
3002
|
+
.brand-logo {
|
|
3003
|
+
font-weight: 700;
|
|
3004
|
+
font-size: 1.1rem;
|
|
3005
|
+
color: var(--accent);
|
|
3006
|
+
}
|
|
3007
|
+
</style>
|
|
3008
|
+
</head>
|
|
3009
|
+
<body>
|
|
3010
|
+
<div class="brand">
|
|
3011
|
+
<span class="brand-logo">IntegSec</span>
|
|
3012
|
+
<span class="subtitle" style="margin: 0">Scope Definition Form</span>
|
|
3013
|
+
</div>
|
|
3014
|
+
|
|
3015
|
+
<form id="scopeForm" onsubmit="return submitForm(event)">
|
|
3016
|
+
<!-- Engagement Info -->
|
|
3017
|
+
<div class="section">
|
|
3018
|
+
<h2>Engagement Info</h2>
|
|
3019
|
+
<label for="engagement_id">Engagement ID</label>
|
|
3020
|
+
<input type="text" id="engagement_id" placeholder="ENG-2026-0001">
|
|
3021
|
+
|
|
3022
|
+
<label for="client">Client Organization</label>
|
|
3023
|
+
<input type="text" id="client" placeholder="Acme Corp">
|
|
3024
|
+
|
|
3025
|
+
<label for="operator">Operator Contact</label>
|
|
3026
|
+
<input type="text" id="operator" placeholder="operator@integsec.com">
|
|
3027
|
+
</div>
|
|
3028
|
+
|
|
3029
|
+
<!-- Engagement Window -->
|
|
3030
|
+
<div class="section">
|
|
3031
|
+
<h2>Engagement Window</h2>
|
|
3032
|
+
<label for="window_start">Start (ISO 8601)</label>
|
|
3033
|
+
<div class="tooltip">Example: 2026-04-01T08:00:00Z</div>
|
|
3034
|
+
<input type="text" id="window_start" placeholder="2026-04-01T08:00:00Z">
|
|
3035
|
+
|
|
3036
|
+
<label for="window_end">End (ISO 8601)</label>
|
|
3037
|
+
<input type="text" id="window_end" placeholder="2026-04-15T17:00:00Z">
|
|
3038
|
+
</div>
|
|
3039
|
+
|
|
3040
|
+
<!-- Authorized Targets -->
|
|
3041
|
+
<div class="section">
|
|
3042
|
+
<h2>Authorized Targets</h2>
|
|
3043
|
+
|
|
3044
|
+
<label for="domains">Domains</label>
|
|
3045
|
+
<div class="tooltip">One per line. Wildcards OK: *.acme.com matches app.acme.com but not acme.com</div>
|
|
3046
|
+
<textarea id="domains" placeholder="*.acme.com acme-staging.example.com"></textarea>
|
|
3047
|
+
|
|
3048
|
+
<label for="ip_ranges">IP Ranges (CIDR)</label>
|
|
3049
|
+
<div class="tooltip">CIDR notation. Example: 10.10.10.0/24 covers 10.10.10.0 through 10.10.10.255</div>
|
|
3050
|
+
<textarea id="ip_ranges" placeholder="10.10.10.0/24 203.0.113.0/28"></textarea>
|
|
3051
|
+
|
|
3052
|
+
<label for="urls">URLs (optional)</label>
|
|
3053
|
+
<div class="tooltip">Reference URLs for the engagement. One per line.</div>
|
|
3054
|
+
<textarea id="urls" placeholder="https://app.acme.com https://api.acme.com"></textarea>
|
|
3055
|
+
|
|
3056
|
+
<label for="cloud_accounts">Cloud Accounts (optional)</label>
|
|
3057
|
+
<div class="tooltip">Format: provider:account-id. Example: aws:123456789012</div>
|
|
3058
|
+
<textarea id="cloud_accounts" placeholder="aws:123456789012"></textarea>
|
|
3059
|
+
</div>
|
|
3060
|
+
|
|
3061
|
+
<!-- Exclusions -->
|
|
3062
|
+
<div class="section">
|
|
3063
|
+
<h2>Exclusions</h2>
|
|
3064
|
+
<label for="excluded_targets">Excluded Targets</label>
|
|
3065
|
+
<div class="tooltip">Targets within scope that must NOT be tested. One per line.</div>
|
|
3066
|
+
<textarea id="excluded_targets" placeholder="hr.acme.com 203.0.113.5"></textarea>
|
|
3067
|
+
</div>
|
|
3068
|
+
|
|
3069
|
+
<!-- Techniques -->
|
|
3070
|
+
<div class="section">
|
|
3071
|
+
<h2>Techniques</h2>
|
|
3072
|
+
|
|
3073
|
+
<label for="authorized_techniques">Authorized Techniques</label>
|
|
3074
|
+
<div class="tooltip">Comma-separated. Common: recon, web_app, api_testing, ssl_tls</div>
|
|
3075
|
+
<input type="text" id="authorized_techniques" placeholder="recon, web_app, api_testing">
|
|
3076
|
+
|
|
3077
|
+
<label for="excluded_techniques">Excluded Techniques</label>
|
|
3078
|
+
<div class="tooltip">Comma-separated. Usually: dos, destructive, social_engineering</div>
|
|
3079
|
+
<input type="text" id="excluded_techniques" placeholder="dos, destructive, social_engineering">
|
|
3080
|
+
</div>
|
|
3081
|
+
|
|
3082
|
+
<button type="submit" id="submitBtn">Submit Scope Definition</button>
|
|
3083
|
+
<div id="result"></div>
|
|
3084
|
+
</form>
|
|
3085
|
+
|
|
3086
|
+
<script>
|
|
3087
|
+
// Load template and lock pre-filled fields
|
|
3088
|
+
fetch("/template")
|
|
3089
|
+
.then((r) => r.json())
|
|
3090
|
+
.then((tpl) => {
|
|
3091
|
+
const fieldMap = {
|
|
3092
|
+
engagement_id: "engagement_id",
|
|
3093
|
+
client: "client",
|
|
3094
|
+
operator: "operator",
|
|
3095
|
+
};
|
|
3096
|
+
for (const [key, id] of Object.entries(fieldMap)) {
|
|
3097
|
+
if (tpl[key]) {
|
|
3098
|
+
const el = document.getElementById(id);
|
|
3099
|
+
el.value = tpl[key];
|
|
3100
|
+
el.disabled = true;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (tpl.engagement_window) {
|
|
3104
|
+
if (tpl.engagement_window.start) {
|
|
3105
|
+
const el = document.getElementById("window_start");
|
|
3106
|
+
el.value = tpl.engagement_window.start;
|
|
3107
|
+
el.disabled = true;
|
|
3108
|
+
}
|
|
3109
|
+
if (tpl.engagement_window.end) {
|
|
3110
|
+
const el = document.getElementById("window_end");
|
|
3111
|
+
el.value = tpl.engagement_window.end;
|
|
3112
|
+
el.disabled = true;
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
if (tpl.excluded_techniques) {
|
|
3116
|
+
const el = document.getElementById("excluded_techniques");
|
|
3117
|
+
el.value = tpl.excluded_techniques.join(", ");
|
|
3118
|
+
el.disabled = true;
|
|
3119
|
+
}
|
|
3120
|
+
})
|
|
3121
|
+
.catch(() => {}); // No template, all fields editable
|
|
3122
|
+
|
|
3123
|
+
// Live validation on text inputs
|
|
3124
|
+
document.querySelectorAll("input[type=text]").forEach((input) => {
|
|
3125
|
+
input.addEventListener("blur", () => {
|
|
3126
|
+
if (input.disabled) return;
|
|
3127
|
+
if (input.value.trim()) {
|
|
3128
|
+
input.classList.add("valid");
|
|
3129
|
+
input.classList.remove("invalid");
|
|
3130
|
+
} else if (input.required || ["engagement_id", "client", "operator"].includes(input.id)) {
|
|
3131
|
+
input.classList.add("invalid");
|
|
3132
|
+
input.classList.remove("valid");
|
|
3133
|
+
}
|
|
3134
|
+
});
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
function parseLines(id) {
|
|
3138
|
+
return document.getElementById(id).value
|
|
3139
|
+
.split("\n")
|
|
3140
|
+
.map((s) => s.trim())
|
|
3141
|
+
.filter(Boolean);
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
function parseComma(id) {
|
|
3145
|
+
return document.getElementById(id).value
|
|
3146
|
+
.split(",")
|
|
3147
|
+
.map((s) => s.trim())
|
|
3148
|
+
.filter(Boolean);
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
function val(id) {
|
|
3152
|
+
return document.getElementById(id).value.trim();
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
async function submitForm(e) {
|
|
3156
|
+
e.preventDefault();
|
|
3157
|
+
const btn = document.getElementById("submitBtn");
|
|
3158
|
+
const result = document.getElementById("result");
|
|
3159
|
+
btn.disabled = true;
|
|
3160
|
+
btn.textContent = "Submitting...";
|
|
3161
|
+
|
|
3162
|
+
const manifest = {
|
|
3163
|
+
engagement_id: val("engagement_id"),
|
|
3164
|
+
client: val("client"),
|
|
3165
|
+
operator: val("operator"),
|
|
3166
|
+
authorized_targets: {
|
|
3167
|
+
ip_ranges: parseLines("ip_ranges"),
|
|
3168
|
+
domains: parseLines("domains"),
|
|
3169
|
+
urls: parseLines("urls"),
|
|
3170
|
+
cloud_accounts: parseLines("cloud_accounts"),
|
|
3171
|
+
},
|
|
3172
|
+
excluded_targets: parseLines("excluded_targets"),
|
|
3173
|
+
authorized_techniques: parseComma("authorized_techniques"),
|
|
3174
|
+
excluded_techniques: parseComma("excluded_techniques"),
|
|
3175
|
+
engagement_window: {
|
|
3176
|
+
start: val("window_start"),
|
|
3177
|
+
end: val("window_end"),
|
|
3178
|
+
},
|
|
3179
|
+
};
|
|
3180
|
+
|
|
3181
|
+
try {
|
|
3182
|
+
const res = await fetch("/submit", {
|
|
3183
|
+
method: "POST",
|
|
3184
|
+
headers: { "Content-Type": "application/json" },
|
|
3185
|
+
body: JSON.stringify(manifest),
|
|
3186
|
+
});
|
|
3187
|
+
const data = await res.json();
|
|
3188
|
+
if (data.success) {
|
|
3189
|
+
result.className = "result success";
|
|
3190
|
+
result.textContent = "Scope definition submitted successfully. Your operator will review it.";
|
|
3191
|
+
} else {
|
|
3192
|
+
result.className = "result error";
|
|
3193
|
+
result.textContent = "Validation failed: " + (data.details?.join(", ") || data.error);
|
|
3194
|
+
}
|
|
3195
|
+
} catch (err) {
|
|
3196
|
+
result.className = "result error";
|
|
3197
|
+
result.textContent = "Network error: " + err.message;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
btn.disabled = false;
|
|
3201
|
+
btn.textContent = "Submit Scope Definition";
|
|
3202
|
+
return false;
|
|
3203
|
+
}
|
|
3204
|
+
</script>
|
|
3205
|
+
</body>
|
|
3206
|
+
</html>
|
|
3207
|
+
```
|
|
3208
|
+
|
|
3209
|
+
- [ ] **Step 2: Commit**
|
|
3210
|
+
|
|
3211
|
+
```bash
|
|
3212
|
+
git add src/builder/web/client-form.html
|
|
3213
|
+
git commit -m "feat(builder): add client intake web form with live validation"
|
|
3214
|
+
```
|
|
3215
|
+
|
|
3216
|
+
---
|
|
3217
|
+
|
|
3218
|
+
### Task 13: Wire `serve` Mode into CLI
|
|
3219
|
+
|
|
3220
|
+
**Files:**
|
|
3221
|
+
- Modify: `bin/integsec-agentic-pentest-manifest.ts`
|
|
3222
|
+
|
|
3223
|
+
- [ ] **Step 1: Update CLI to support serve mode**
|
|
3224
|
+
|
|
3225
|
+
In `bin/integsec-agentic-pentest-manifest.ts`, add serve-mode args to `parseArgs()`:
|
|
3226
|
+
|
|
3227
|
+
```typescript
|
|
3228
|
+
// Add to the return type:
|
|
3229
|
+
template?: string;
|
|
3230
|
+
host?: string;
|
|
3231
|
+
port?: number;
|
|
3232
|
+
tlsCert?: string;
|
|
3233
|
+
tlsKey?: string;
|
|
3234
|
+
timeout?: number;
|
|
3235
|
+
```
|
|
3236
|
+
|
|
3237
|
+
Add arg parsing in the for loop:
|
|
3238
|
+
|
|
3239
|
+
```typescript
|
|
3240
|
+
} else if (args[i] === "--template" && args[i + 1]) {
|
|
3241
|
+
fromTemplate = args[i + 1]; // reuse fromTemplate for serve template
|
|
3242
|
+
i++;
|
|
3243
|
+
} else if (args[i] === "--host" && args[i + 1]) {
|
|
3244
|
+
// Store host in a new variable
|
|
3245
|
+
i++;
|
|
3246
|
+
} else if (args[i] === "--port" && args[i + 1]) {
|
|
3247
|
+
i++;
|
|
3248
|
+
} else if (args[i] === "--tls-cert" && args[i + 1]) {
|
|
3249
|
+
i++;
|
|
3250
|
+
} else if (args[i] === "--tls-key" && args[i + 1]) {
|
|
3251
|
+
i++;
|
|
3252
|
+
} else if (args[i] === "--timeout" && args[i + 1]) {
|
|
3253
|
+
i++;
|
|
3254
|
+
}
|
|
3255
|
+
```
|
|
3256
|
+
|
|
3257
|
+
Replace the serve mode placeholder:
|
|
3258
|
+
|
|
3259
|
+
```typescript
|
|
3260
|
+
} else if (config.mode === "serve") {
|
|
3261
|
+
const template = config.fromTemplate ? loadTemplate(config.fromTemplate) : undefined;
|
|
3262
|
+
|
|
3263
|
+
// Parse serve-specific args
|
|
3264
|
+
const args = process.argv.slice(2);
|
|
3265
|
+
let host = "127.0.0.1";
|
|
3266
|
+
let port = 9471;
|
|
3267
|
+
let tlsCert: string | undefined;
|
|
3268
|
+
let tlsKey: string | undefined;
|
|
3269
|
+
let timeout = 60;
|
|
3270
|
+
|
|
3271
|
+
for (let i = 0; i < args.length; i++) {
|
|
3272
|
+
if (args[i] === "--host" && args[i + 1]) { host = args[i + 1]; i++; }
|
|
3273
|
+
if (args[i] === "--port" && args[i + 1]) { port = parseInt(args[i + 1], 10); i++; }
|
|
3274
|
+
if (args[i] === "--tls-cert" && args[i + 1]) { tlsCert = args[i + 1]; i++; }
|
|
3275
|
+
if (args[i] === "--tls-key" && args[i + 1]) { tlsKey = args[i + 1]; i++; }
|
|
3276
|
+
if (args[i] === "--timeout" && args[i + 1]) { timeout = parseInt(args[i + 1], 10); i++; }
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
const { startServer } = await import("../src/builder/web/server.js");
|
|
3280
|
+
startServer({
|
|
3281
|
+
port,
|
|
3282
|
+
host,
|
|
3283
|
+
template,
|
|
3284
|
+
outputDir: config.output ?? "./submissions",
|
|
3285
|
+
tlsCert,
|
|
3286
|
+
tlsKey,
|
|
3287
|
+
timeoutMinutes: timeout,
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
```
|
|
3291
|
+
|
|
3292
|
+
- [ ] **Step 2: Build and smoke test**
|
|
3293
|
+
|
|
3294
|
+
Run: `npx tsc && node dist/bin/integsec-agentic-pentest-manifest.js serve --help`
|
|
3295
|
+
Expected: Help text shows serve mode
|
|
3296
|
+
|
|
3297
|
+
- [ ] **Step 3: Commit**
|
|
3298
|
+
|
|
3299
|
+
```bash
|
|
3300
|
+
git add bin/integsec-agentic-pentest-manifest.ts
|
|
3301
|
+
git commit -m "feat(builder): wire serve mode into CLI with TLS and timeout options"
|
|
3302
|
+
```
|
|
3303
|
+
|
|
3304
|
+
---
|
|
3305
|
+
|
|
3306
|
+
## Phase 4: README & Polish
|
|
3307
|
+
|
|
3308
|
+
### Task 14: Update README
|
|
3309
|
+
|
|
3310
|
+
**Files:**
|
|
3311
|
+
- Modify: `README.md`
|
|
3312
|
+
|
|
3313
|
+
- [ ] **Step 1: Read current README**
|
|
3314
|
+
|
|
3315
|
+
Read `README.md` to understand current structure and find the right insertion point.
|
|
3316
|
+
|
|
3317
|
+
- [ ] **Step 2: Add Manifest Builder section**
|
|
3318
|
+
|
|
3319
|
+
Add a new section after the existing usage/configuration sections:
|
|
3320
|
+
|
|
3321
|
+
```markdown
|
|
3322
|
+
## Manifest Builder
|
|
3323
|
+
|
|
3324
|
+
Build scope manifests interactively with smart validation, or share a web form with clients for scope intake.
|
|
3325
|
+
|
|
3326
|
+
### Quick Start
|
|
3327
|
+
|
|
3328
|
+
```bash
|
|
3329
|
+
# Create a new manifest interactively
|
|
3330
|
+
integsec-agentic-pentest-manifest new
|
|
3331
|
+
|
|
3332
|
+
# Create from a template
|
|
3333
|
+
integsec-agentic-pentest-manifest new --from template.json -o scope.json
|
|
3334
|
+
|
|
3335
|
+
# Edit an existing manifest
|
|
3336
|
+
integsec-agentic-pentest-manifest edit scope.json
|
|
3337
|
+
|
|
3338
|
+
# Review a client submission
|
|
3339
|
+
integsec-agentic-pentest-manifest review submission.pending.json
|
|
3340
|
+
```
|
|
3341
|
+
|
|
3342
|
+
### Client Intake (Web Form)
|
|
3343
|
+
|
|
3344
|
+
Share a web-based form with clients so they can define their own scope:
|
|
3345
|
+
|
|
3346
|
+
```bash
|
|
3347
|
+
# Local only (default — localhost:9471)
|
|
3348
|
+
integsec-agentic-pentest-manifest serve --template engagement-template.json
|
|
3349
|
+
|
|
3350
|
+
# Network-accessible (TLS required)
|
|
3351
|
+
integsec-agentic-pentest-manifest serve \
|
|
3352
|
+
--host 0.0.0.0 \
|
|
3353
|
+
--tls-cert ./certs/server.pem \
|
|
3354
|
+
--tls-key ./certs/server-key.pem \
|
|
3355
|
+
--template engagement-template.json
|
|
3356
|
+
```
|
|
3357
|
+
|
|
3358
|
+
**Workflow:**
|
|
3359
|
+
1. Operator creates a template with engagement info, time window, and technique restrictions
|
|
3360
|
+
2. Operator runs `serve` and shares the URL with the client
|
|
3361
|
+
3. Client fills in their targets (domains, IPs, exclusions) via the web form
|
|
3362
|
+
4. Client submits → saved as `.pending.json`
|
|
3363
|
+
5. Operator reviews: `integsec-agentic-pentest-manifest review submission.pending.json`
|
|
3364
|
+
6. Operator approves, edits, or rejects
|
|
3365
|
+
|
|
3366
|
+
**Security:** The web server binds to `127.0.0.1` by default. Exposing on `0.0.0.0` requires `--tls-cert` and `--tls-key` — the server refuses to start over plaintext on non-localhost interfaces.
|
|
3367
|
+
|
|
3368
|
+
### Smart Validation
|
|
3369
|
+
|
|
3370
|
+
The builder validates your manifest as you build it:
|
|
3371
|
+
|
|
3372
|
+
- **Format checks** — valid CIDRs, ISO 8601 dates, non-empty required fields
|
|
3373
|
+
- **Live DNS resolution** — resolves domains and shows their IPs
|
|
3374
|
+
- **Overlap detection** — warns when domains resolve to IPs already covered by CIDR ranges
|
|
3375
|
+
- **Broad range warnings** — flags CIDR ranges wider than /16
|
|
3376
|
+
- **Wildcard/apex prompts** — suggests adding `acme.com` when `*.acme.com` is entered
|
|
3377
|
+
- **Exclusion checks** — warns if an excluded target isn't within any authorized range
|
|
3378
|
+
|
|
3379
|
+
### Dry-Run Preview
|
|
3380
|
+
|
|
3381
|
+
Before saving, the builder runs synthetic tool calls against your manifest to preview what the proxy will allow and block. This catches scope misconfigurations before an engagement starts.
|
|
3382
|
+
```
|
|
3383
|
+
|
|
3384
|
+
- [ ] **Step 3: Commit**
|
|
3385
|
+
|
|
3386
|
+
```bash
|
|
3387
|
+
git add README.md
|
|
3388
|
+
git commit -m "docs: add Manifest Builder section to README"
|
|
3389
|
+
```
|
|
3390
|
+
|
|
3391
|
+
---
|
|
3392
|
+
|
|
3393
|
+
### Task 15: Full Build, Test, and Polish
|
|
3394
|
+
|
|
3395
|
+
**Files:**
|
|
3396
|
+
- All project files
|
|
3397
|
+
|
|
3398
|
+
- [ ] **Step 1: Build the project**
|
|
3399
|
+
|
|
3400
|
+
Run: `npx tsc`
|
|
3401
|
+
Expected: No type errors
|
|
3402
|
+
|
|
3403
|
+
- [ ] **Step 2: Run full test suite**
|
|
3404
|
+
|
|
3405
|
+
Run: `npx vitest run`
|
|
3406
|
+
Expected: All tests pass
|
|
3407
|
+
|
|
3408
|
+
- [ ] **Step 3: Fix any remaining issues**
|
|
3409
|
+
|
|
3410
|
+
Address any type errors, test failures, or lint issues.
|
|
3411
|
+
|
|
3412
|
+
- [ ] **Step 4: Final commit**
|
|
3413
|
+
|
|
3414
|
+
```bash
|
|
3415
|
+
git add -A
|
|
3416
|
+
git commit -m "chore(builder): Phase 4 polish — resolve remaining issues"
|
|
3417
|
+
```
|
|
3418
|
+
|
|
3419
|
+
- [ ] **Step 5: Verify the three entry points work**
|
|
3420
|
+
|
|
3421
|
+
Run:
|
|
3422
|
+
```bash
|
|
3423
|
+
node dist/bin/integsec-agentic-pentest-manifest.js --help
|
|
3424
|
+
node dist/bin/integsec-agentic-pentest-manifest.js serve &
|
|
3425
|
+
curl http://localhost:9471/ -s | head -5
|
|
3426
|
+
kill %1
|
|
3427
|
+
```
|
|
3428
|
+
|
|
3429
|
+
Expected: Help prints, server starts, HTML form is served.
|