@vulcn/engine 0.9.2 → 0.9.3
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/CHANGELOG.md +41 -0
- package/dist/index.cjs +707 -363
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +663 -148
- package/dist/index.d.ts +663 -148
- package/dist/index.js +685 -355
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,398 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var ScanConfigSchema = z.object({
|
|
4
|
+
/** Browser engine to use */
|
|
5
|
+
browser: z.enum(["chromium", "firefox", "webkit"]).default("chromium"),
|
|
6
|
+
/** Run in headless mode */
|
|
7
|
+
headless: z.boolean().default(true),
|
|
8
|
+
/** Per-step timeout in ms */
|
|
9
|
+
timeout: z.number().positive().default(3e4)
|
|
10
|
+
}).default({});
|
|
11
|
+
var PayloadsConfigSchema = z.object({
|
|
12
|
+
/** Payload types to use */
|
|
13
|
+
types: z.array(z.enum(["xss", "sqli", "xxe", "cmd", "redirect", "traversal"])).default(["xss"]),
|
|
14
|
+
/** Opt-in to PayloadsAllTheThings community payloads */
|
|
15
|
+
payloadbox: z.boolean().default(false),
|
|
16
|
+
/** Max payloads per type from PayloadBox */
|
|
17
|
+
limit: z.number().positive().default(100),
|
|
18
|
+
/** Path to custom payload YAML file (relative to project root) */
|
|
19
|
+
custom: z.string().nullable().default(null)
|
|
20
|
+
}).default({});
|
|
21
|
+
var XssDetectionSchema = z.object({
|
|
22
|
+
/** Monitor alert/confirm/prompt dialogs */
|
|
23
|
+
dialogs: z.boolean().default(true),
|
|
24
|
+
/** Monitor console.log markers */
|
|
25
|
+
console: z.boolean().default(true),
|
|
26
|
+
/** Console marker prefix */
|
|
27
|
+
consoleMarker: z.string().default("VULCN_XSS:"),
|
|
28
|
+
/** Check for injected <script> elements */
|
|
29
|
+
domMutation: z.boolean().default(false),
|
|
30
|
+
/** Finding severity level */
|
|
31
|
+
severity: z.enum(["critical", "high", "medium", "low"]).default("high"),
|
|
32
|
+
/** Text patterns to match in alert messages */
|
|
33
|
+
alertPatterns: z.array(z.string()).default([
|
|
34
|
+
"XSS",
|
|
35
|
+
"1",
|
|
36
|
+
"document.domain",
|
|
37
|
+
"document.cookie",
|
|
38
|
+
"vulcn",
|
|
39
|
+
"pwned"
|
|
40
|
+
])
|
|
41
|
+
}).default({});
|
|
42
|
+
var ReflectionSeveritySchema = z.object({
|
|
43
|
+
script: z.enum(["critical", "high", "medium", "low"]).default("critical"),
|
|
44
|
+
attribute: z.enum(["critical", "high", "medium", "low"]).default("medium"),
|
|
45
|
+
body: z.enum(["critical", "high", "medium", "low"]).default("low")
|
|
46
|
+
}).default({});
|
|
47
|
+
var ReflectionContextsSchema = z.object({
|
|
48
|
+
script: z.boolean().default(true),
|
|
49
|
+
attribute: z.boolean().default(true),
|
|
50
|
+
body: z.boolean().default(true)
|
|
51
|
+
}).default({});
|
|
52
|
+
var ReflectionDetectionSchema = z.object({
|
|
53
|
+
/** Enable reflection detection */
|
|
54
|
+
enabled: z.boolean().default(true),
|
|
55
|
+
/** Minimum payload length to check */
|
|
56
|
+
minLength: z.number().positive().default(4),
|
|
57
|
+
/** Which HTML contexts to check for reflections */
|
|
58
|
+
contexts: ReflectionContextsSchema,
|
|
59
|
+
/** Severity per context */
|
|
60
|
+
severity: ReflectionSeveritySchema
|
|
61
|
+
}).default({});
|
|
62
|
+
var DetectionConfigSchema = z.object({
|
|
63
|
+
/** XSS detection settings */
|
|
64
|
+
xss: XssDetectionSchema,
|
|
65
|
+
/** Reflection detection settings */
|
|
66
|
+
reflection: ReflectionDetectionSchema,
|
|
67
|
+
/** Enable passive security checks (headers, cookies, info-disclosure) */
|
|
68
|
+
passive: z.boolean().default(true)
|
|
69
|
+
}).default({});
|
|
70
|
+
var CrawlConfigSchema = z.object({
|
|
71
|
+
/** Maximum crawl depth */
|
|
72
|
+
depth: z.number().nonnegative().default(2),
|
|
73
|
+
/** Maximum pages to visit */
|
|
74
|
+
maxPages: z.number().positive().default(20),
|
|
75
|
+
/** Stay on same origin */
|
|
76
|
+
sameOrigin: z.boolean().default(true),
|
|
77
|
+
/** Per-page timeout in ms */
|
|
78
|
+
timeout: z.number().positive().default(1e4)
|
|
79
|
+
}).default({});
|
|
80
|
+
var ReportConfigSchema = z.object({
|
|
81
|
+
/** Report format to generate */
|
|
82
|
+
format: z.enum(["html", "json", "yaml", "sarif", "all"]).nullable().default(null)
|
|
83
|
+
}).default({});
|
|
84
|
+
var FormAuthSchema = z.object({
|
|
85
|
+
strategy: z.literal("form"),
|
|
86
|
+
/** Login page URL */
|
|
87
|
+
loginUrl: z.string().url().optional(),
|
|
88
|
+
/** CSS selector for username field */
|
|
89
|
+
userSelector: z.string().nullable().default(null),
|
|
90
|
+
/** CSS selector for password field */
|
|
91
|
+
passSelector: z.string().nullable().default(null)
|
|
92
|
+
});
|
|
93
|
+
var HeaderAuthSchema = z.object({
|
|
94
|
+
strategy: z.literal("header"),
|
|
95
|
+
/** Headers to include in requests */
|
|
96
|
+
headers: z.record(z.string())
|
|
97
|
+
});
|
|
98
|
+
var AuthConfigSchema = z.discriminatedUnion("strategy", [FormAuthSchema, HeaderAuthSchema]).nullable().default(null);
|
|
99
|
+
var VulcnProjectConfigSchema = z.object({
|
|
100
|
+
/** Target URL to scan */
|
|
101
|
+
target: z.string().url().optional(),
|
|
102
|
+
/** Scan settings (browser, headless, timeout) */
|
|
103
|
+
scan: ScanConfigSchema,
|
|
104
|
+
/** Payload configuration */
|
|
105
|
+
payloads: PayloadsConfigSchema,
|
|
106
|
+
/** Detection configuration */
|
|
107
|
+
detection: DetectionConfigSchema,
|
|
108
|
+
/** Crawl configuration */
|
|
109
|
+
crawl: CrawlConfigSchema,
|
|
110
|
+
/** Report configuration */
|
|
111
|
+
report: ReportConfigSchema,
|
|
112
|
+
/** Authentication configuration */
|
|
113
|
+
auth: AuthConfigSchema
|
|
114
|
+
});
|
|
115
|
+
function parseProjectConfig(raw) {
|
|
116
|
+
return VulcnProjectConfigSchema.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
var DEFAULT_PROJECT_CONFIG = {
|
|
119
|
+
target: "https://example.com",
|
|
120
|
+
scan: {
|
|
121
|
+
browser: "chromium",
|
|
122
|
+
headless: true,
|
|
123
|
+
timeout: 3e4
|
|
124
|
+
},
|
|
125
|
+
payloads: {
|
|
126
|
+
types: ["xss"]
|
|
127
|
+
},
|
|
128
|
+
detection: {
|
|
129
|
+
xss: {
|
|
130
|
+
dialogs: true,
|
|
131
|
+
console: true,
|
|
132
|
+
domMutation: false,
|
|
133
|
+
severity: "high"
|
|
134
|
+
},
|
|
135
|
+
reflection: {
|
|
136
|
+
enabled: true
|
|
137
|
+
},
|
|
138
|
+
passive: true
|
|
139
|
+
},
|
|
140
|
+
crawl: {
|
|
141
|
+
depth: 2,
|
|
142
|
+
maxPages: 20,
|
|
143
|
+
sameOrigin: true
|
|
144
|
+
},
|
|
145
|
+
report: {
|
|
146
|
+
format: "html"
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/project.ts
|
|
151
|
+
import { readFile, mkdir } from "fs/promises";
|
|
152
|
+
import { existsSync } from "fs";
|
|
153
|
+
import { resolve, dirname, join } from "path";
|
|
154
|
+
import YAML from "yaml";
|
|
155
|
+
var CONFIG_FILENAME = ".vulcn.yml";
|
|
156
|
+
var DIRS = {
|
|
157
|
+
sessions: "sessions",
|
|
158
|
+
auth: "auth",
|
|
159
|
+
reports: "reports"
|
|
160
|
+
};
|
|
161
|
+
function findProjectRoot(startDir) {
|
|
162
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
163
|
+
while (true) {
|
|
164
|
+
const configPath = join(dir, CONFIG_FILENAME);
|
|
165
|
+
if (existsSync(configPath)) {
|
|
166
|
+
return dir;
|
|
167
|
+
}
|
|
168
|
+
const parent = dirname(dir);
|
|
169
|
+
if (parent === dir) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
dir = parent;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function resolveProjectPaths(root) {
|
|
176
|
+
return {
|
|
177
|
+
root,
|
|
178
|
+
config: join(root, CONFIG_FILENAME),
|
|
179
|
+
sessions: join(root, DIRS.sessions),
|
|
180
|
+
auth: join(root, DIRS.auth),
|
|
181
|
+
reports: join(root, DIRS.reports)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function loadProject(startDir) {
|
|
185
|
+
const root = findProjectRoot(startDir);
|
|
186
|
+
if (!root) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`No ${CONFIG_FILENAME} found. Run \`vulcn init\` to create one.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const paths = resolveProjectPaths(root);
|
|
192
|
+
const raw = await readFile(paths.config, "utf-8");
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = YAML.parse(raw);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Invalid YAML in ${paths.config}: ${err instanceof Error ? err.message : String(err)}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (parsed === null || parsed === void 0) {
|
|
202
|
+
parsed = {};
|
|
203
|
+
}
|
|
204
|
+
const config = parseProjectConfig(parsed);
|
|
205
|
+
return { config, paths };
|
|
206
|
+
}
|
|
207
|
+
async function loadProjectFromFile(configPath) {
|
|
208
|
+
const absPath = resolve(configPath);
|
|
209
|
+
const root = dirname(absPath);
|
|
210
|
+
const paths = resolveProjectPaths(root);
|
|
211
|
+
const raw = await readFile(absPath, "utf-8");
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = YAML.parse(raw);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Invalid YAML in ${absPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (parsed === null || parsed === void 0) {
|
|
221
|
+
parsed = {};
|
|
222
|
+
}
|
|
223
|
+
const config = parseProjectConfig(parsed);
|
|
224
|
+
return { config, paths };
|
|
225
|
+
}
|
|
226
|
+
async function ensureProjectDirs(paths, dirs = ["sessions"]) {
|
|
227
|
+
for (const dir of dirs) {
|
|
228
|
+
const dirPath = paths[dir];
|
|
229
|
+
if (!existsSync(dirPath)) {
|
|
230
|
+
await mkdir(dirPath, { recursive: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
1
235
|
// src/driver-manager.ts
|
|
2
|
-
import { isAbsolute, resolve } from "path";
|
|
236
|
+
import { isAbsolute, resolve as resolve2 } from "path";
|
|
237
|
+
import { createRequire } from "module";
|
|
3
238
|
import { parse } from "yaml";
|
|
239
|
+
|
|
240
|
+
// src/errors.ts
|
|
241
|
+
var ErrorSeverity = /* @__PURE__ */ ((ErrorSeverity2) => {
|
|
242
|
+
ErrorSeverity2["FATAL"] = "fatal";
|
|
243
|
+
ErrorSeverity2["ERROR"] = "error";
|
|
244
|
+
ErrorSeverity2["WARN"] = "warn";
|
|
245
|
+
return ErrorSeverity2;
|
|
246
|
+
})(ErrorSeverity || {});
|
|
247
|
+
var VulcnError = class _VulcnError extends Error {
|
|
248
|
+
severity;
|
|
249
|
+
source;
|
|
250
|
+
context;
|
|
251
|
+
timestamp;
|
|
252
|
+
constructor(message, options) {
|
|
253
|
+
super(message, { cause: options.cause });
|
|
254
|
+
this.name = "VulcnError";
|
|
255
|
+
this.severity = options.severity;
|
|
256
|
+
this.source = options.source;
|
|
257
|
+
this.context = options.context;
|
|
258
|
+
this.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Wrap any caught error into a VulcnError.
|
|
262
|
+
* If it's already a VulcnError, returns it as-is.
|
|
263
|
+
*/
|
|
264
|
+
static from(err, defaults) {
|
|
265
|
+
if (err instanceof _VulcnError) return err;
|
|
266
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
267
|
+
return new _VulcnError(message, {
|
|
268
|
+
severity: defaults.severity,
|
|
269
|
+
source: defaults.source,
|
|
270
|
+
cause: err,
|
|
271
|
+
context: defaults.context
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
function fatal(message, source, options) {
|
|
276
|
+
return new VulcnError(message, {
|
|
277
|
+
severity: "fatal" /* FATAL */,
|
|
278
|
+
source,
|
|
279
|
+
...options
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function error(message, source, options) {
|
|
283
|
+
return new VulcnError(message, {
|
|
284
|
+
severity: "error" /* ERROR */,
|
|
285
|
+
source,
|
|
286
|
+
...options
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function warn(message, source, options) {
|
|
290
|
+
return new VulcnError(message, {
|
|
291
|
+
severity: "warn" /* WARN */,
|
|
292
|
+
source,
|
|
293
|
+
...options
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
var ErrorHandler = class {
|
|
297
|
+
errors = [];
|
|
298
|
+
listeners = [];
|
|
299
|
+
/**
|
|
300
|
+
* Handle an error based on its severity.
|
|
301
|
+
*
|
|
302
|
+
* - FATAL: logs, records, then THROWS (caller must not catch silently)
|
|
303
|
+
* - ERROR: logs and records
|
|
304
|
+
* - WARN: logs only
|
|
305
|
+
*/
|
|
306
|
+
handle(err) {
|
|
307
|
+
this.errors.push(err);
|
|
308
|
+
for (const listener of this.listeners) {
|
|
309
|
+
try {
|
|
310
|
+
listener(err);
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const ctx = err.context ? ` ${JSON.stringify(err.context)}` : "";
|
|
315
|
+
switch (err.severity) {
|
|
316
|
+
case "fatal" /* FATAL */:
|
|
317
|
+
console.error(`\u274C FATAL [${err.source}] ${err.message}${ctx}`);
|
|
318
|
+
if (err.cause instanceof Error) {
|
|
319
|
+
console.error(` Caused by: ${err.cause.message}`);
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
// ← This is the whole point. FATAL stops execution.
|
|
323
|
+
case "error" /* ERROR */:
|
|
324
|
+
console.error(`\u26A0\uFE0F ERROR [${err.source}] ${err.message}${ctx}`);
|
|
325
|
+
break;
|
|
326
|
+
case "warn" /* WARN */:
|
|
327
|
+
console.warn(`\u26A1 WARN [${err.source}] ${err.message}${ctx}`);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Convenience: wrap a caught error and handle it.
|
|
333
|
+
*/
|
|
334
|
+
catch(err, defaults) {
|
|
335
|
+
this.handle(VulcnError.from(err, defaults));
|
|
336
|
+
}
|
|
337
|
+
// ── Query ──────────────────────────────────────────────────────────
|
|
338
|
+
/** All recorded errors (FATAL + ERROR + WARN) */
|
|
339
|
+
getAll() {
|
|
340
|
+
return [...this.errors];
|
|
341
|
+
}
|
|
342
|
+
/** Only ERROR and FATAL */
|
|
343
|
+
getErrors() {
|
|
344
|
+
return this.errors.filter(
|
|
345
|
+
(e) => e.severity === "error" /* ERROR */ || e.severity === "fatal" /* FATAL */
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
/** Were there any errors (not just warnings)? */
|
|
349
|
+
hasErrors() {
|
|
350
|
+
return this.errors.some(
|
|
351
|
+
(e) => e.severity === "error" /* ERROR */ || e.severity === "fatal" /* FATAL */
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
/** Count by severity */
|
|
355
|
+
counts() {
|
|
356
|
+
const counts = {
|
|
357
|
+
["fatal" /* FATAL */]: 0,
|
|
358
|
+
["error" /* ERROR */]: 0,
|
|
359
|
+
["warn" /* WARN */]: 0
|
|
360
|
+
};
|
|
361
|
+
for (const e of this.errors) {
|
|
362
|
+
counts[e.severity]++;
|
|
363
|
+
}
|
|
364
|
+
return counts;
|
|
365
|
+
}
|
|
366
|
+
/** Human-readable summary for end-of-run reporting */
|
|
367
|
+
getSummary() {
|
|
368
|
+
if (this.errors.length === 0) return "No errors.";
|
|
369
|
+
const c = this.counts();
|
|
370
|
+
const lines = [
|
|
371
|
+
`Error Summary: ${c.fatal} fatal, ${c.error} errors, ${c.warn} warnings`
|
|
372
|
+
];
|
|
373
|
+
for (const e of this.errors) {
|
|
374
|
+
const icon = e.severity === "fatal" /* FATAL */ ? "\u274C" : e.severity === "error" /* ERROR */ ? "\u26A0\uFE0F " : "\u26A1";
|
|
375
|
+
lines.push(` ${icon} [${e.source}] ${e.message}`);
|
|
376
|
+
}
|
|
377
|
+
return lines.join("\n");
|
|
378
|
+
}
|
|
379
|
+
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
380
|
+
/** Subscribe to errors as they happen */
|
|
381
|
+
onError(listener) {
|
|
382
|
+
this.listeners.push(listener);
|
|
383
|
+
return () => {
|
|
384
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** Reset for a new run */
|
|
388
|
+
clear() {
|
|
389
|
+
this.errors = [];
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// src/driver-manager.ts
|
|
394
|
+
var require2 = createRequire(import.meta.url);
|
|
395
|
+
var { version: ENGINE_VERSION } = require2("../package.json");
|
|
4
396
|
var DriverManager = class {
|
|
5
397
|
drivers = /* @__PURE__ */ new Map();
|
|
6
398
|
defaultDriver = null;
|
|
@@ -21,7 +413,7 @@ var DriverManager = class {
|
|
|
21
413
|
let driver;
|
|
22
414
|
let source;
|
|
23
415
|
if (nameOrPath.startsWith("./") || nameOrPath.startsWith("../") || isAbsolute(nameOrPath)) {
|
|
24
|
-
const resolved = isAbsolute(nameOrPath) ? nameOrPath :
|
|
416
|
+
const resolved = isAbsolute(nameOrPath) ? nameOrPath : resolve2(process.cwd(), nameOrPath);
|
|
25
417
|
const module = await import(resolved);
|
|
26
418
|
driver = module.default || module;
|
|
27
419
|
source = "local";
|
|
@@ -82,44 +474,18 @@ var DriverManager = class {
|
|
|
82
474
|
/**
|
|
83
475
|
* Parse a YAML session string into a Session object.
|
|
84
476
|
*
|
|
85
|
-
*
|
|
86
|
-
* Legacy sessions (those with non-namespaced step types like "click",
|
|
87
|
-
* "input", "navigate") are automatically converted to the driver format
|
|
88
|
-
* (e.g., "browser.click", "browser.input", "browser.navigate").
|
|
477
|
+
* Sessions must use the driver format with a `driver` field.
|
|
89
478
|
*
|
|
90
479
|
* @param yaml - Raw YAML string
|
|
91
|
-
* @param defaultDriver - Driver to assign for legacy sessions (default: "browser")
|
|
92
480
|
*/
|
|
93
|
-
parseSession(yaml
|
|
481
|
+
parseSession(yaml) {
|
|
94
482
|
const data = parse(yaml);
|
|
95
|
-
if (data.driver
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (type.includes(".")) {
|
|
102
|
-
return step;
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
...step,
|
|
106
|
-
type: `${defaultDriver}.${type}`
|
|
107
|
-
};
|
|
108
|
-
});
|
|
109
|
-
return {
|
|
110
|
-
name: data.name ?? "Untitled Session",
|
|
111
|
-
driver: defaultDriver,
|
|
112
|
-
driverConfig: {
|
|
113
|
-
browser: data.browser ?? "chromium",
|
|
114
|
-
viewport: data.viewport ?? { width: 1280, height: 720 },
|
|
115
|
-
startUrl: data.startUrl
|
|
116
|
-
},
|
|
117
|
-
steps: convertedSteps,
|
|
118
|
-
metadata: {
|
|
119
|
-
recordedAt: data.recordedAt,
|
|
120
|
-
version: data.version ?? "1"
|
|
121
|
-
}
|
|
122
|
-
};
|
|
483
|
+
if (!data.driver || typeof data.driver !== "string") {
|
|
484
|
+
throw new Error(
|
|
485
|
+
"Invalid session format: missing 'driver' field. Sessions must use the driver format."
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return data;
|
|
123
489
|
}
|
|
124
490
|
/**
|
|
125
491
|
* Start recording with a driver
|
|
@@ -173,11 +539,12 @@ var DriverManager = class {
|
|
|
173
539
|
page: null,
|
|
174
540
|
headless: !!options.headless,
|
|
175
541
|
config: {},
|
|
176
|
-
engine: { version:
|
|
542
|
+
engine: { version: ENGINE_VERSION, pluginApiVersion: 1 },
|
|
177
543
|
payloads: pluginManager2.getPayloads(),
|
|
178
544
|
findings,
|
|
179
545
|
addFinding,
|
|
180
546
|
logger,
|
|
547
|
+
errors: pluginManager2.getErrorHandler(),
|
|
181
548
|
fetch: globalThis.fetch
|
|
182
549
|
};
|
|
183
550
|
const ctx = {
|
|
@@ -187,6 +554,7 @@ var DriverManager = class {
|
|
|
187
554
|
findings,
|
|
188
555
|
addFinding,
|
|
189
556
|
logger,
|
|
557
|
+
errors: pluginManager2.getErrorHandler(),
|
|
190
558
|
options: {
|
|
191
559
|
...options,
|
|
192
560
|
// Provide onPageReady callback — fires plugin onRunStart hooks
|
|
@@ -201,9 +569,11 @@ var DriverManager = class {
|
|
|
201
569
|
config: loaded.config
|
|
202
570
|
});
|
|
203
571
|
} catch (err) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
572
|
+
pluginManager2.getErrorHandler().catch(err, {
|
|
573
|
+
severity: "error" /* ERROR */,
|
|
574
|
+
source: `plugin:${loaded.plugin.name}`,
|
|
575
|
+
context: { hook: "onRunStart" }
|
|
576
|
+
});
|
|
207
577
|
}
|
|
208
578
|
}
|
|
209
579
|
}
|
|
@@ -218,9 +588,11 @@ var DriverManager = class {
|
|
|
218
588
|
config: loaded.config
|
|
219
589
|
});
|
|
220
590
|
} catch (err) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
591
|
+
pluginManager2.getErrorHandler().catch(err, {
|
|
592
|
+
severity: "warn" /* WARN */,
|
|
593
|
+
source: `plugin:${loaded.plugin.name}`,
|
|
594
|
+
context: { hook: "onBeforeClose" }
|
|
595
|
+
});
|
|
224
596
|
}
|
|
225
597
|
}
|
|
226
598
|
}
|
|
@@ -237,7 +609,11 @@ var DriverManager = class {
|
|
|
237
609
|
findings: result.findings
|
|
238
610
|
});
|
|
239
611
|
} catch (err) {
|
|
240
|
-
|
|
612
|
+
pluginManager2.getErrorHandler().catch(err, {
|
|
613
|
+
severity: "fatal" /* FATAL */,
|
|
614
|
+
source: `plugin:${loaded.plugin.name}`,
|
|
615
|
+
context: { hook: "onRunEnd" }
|
|
616
|
+
});
|
|
241
617
|
}
|
|
242
618
|
}
|
|
243
619
|
}
|
|
@@ -275,24 +651,23 @@ var DriverManager = class {
|
|
|
275
651
|
const allErrors = [];
|
|
276
652
|
const firstDriver = this.getForSession(sessions[0]);
|
|
277
653
|
let sharedBrowser = null;
|
|
278
|
-
if (firstDriver.
|
|
654
|
+
if (typeof firstDriver.createSharedResource === "function") {
|
|
279
655
|
try {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
656
|
+
const driverConfig = sessions[0].driverConfig;
|
|
657
|
+
sharedBrowser = await firstDriver.createSharedResource(
|
|
658
|
+
driverConfig,
|
|
659
|
+
options
|
|
284
660
|
);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
661
|
+
} catch (err) {
|
|
662
|
+
pluginManager2.getErrorHandler().catch(err, {
|
|
663
|
+
severity: "warn" /* WARN */,
|
|
664
|
+
source: `driver-manager:${firstDriver.name}`,
|
|
665
|
+
context: { action: "create-shared-resource" }
|
|
290
666
|
});
|
|
291
|
-
sharedBrowser = result.browser;
|
|
292
|
-
} catch {
|
|
293
667
|
}
|
|
294
668
|
}
|
|
295
669
|
try {
|
|
670
|
+
await pluginManager2.initialize();
|
|
296
671
|
await pluginManager2.callHook("onScanStart", async (hook, ctx) => {
|
|
297
672
|
const scanCtx = {
|
|
298
673
|
...ctx,
|
|
@@ -302,21 +677,63 @@ var DriverManager = class {
|
|
|
302
677
|
};
|
|
303
678
|
await hook(scanCtx);
|
|
304
679
|
});
|
|
305
|
-
for (
|
|
680
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
681
|
+
const session = sessions[i];
|
|
682
|
+
pluginManager2.clearFindings();
|
|
683
|
+
options.onSessionStart?.(session, i, sessions.length);
|
|
306
684
|
const sessionOptions = {
|
|
307
685
|
...options,
|
|
308
686
|
...sharedBrowser ? { browser: sharedBrowser } : {}
|
|
309
687
|
};
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
688
|
+
let result;
|
|
689
|
+
if (options.timeout && options.timeout > 0) {
|
|
690
|
+
const execPromise = this.execute(
|
|
691
|
+
session,
|
|
692
|
+
pluginManager2,
|
|
693
|
+
sessionOptions
|
|
694
|
+
);
|
|
695
|
+
const timeoutPromise = new Promise(
|
|
696
|
+
(_, reject) => setTimeout(
|
|
697
|
+
() => reject(
|
|
698
|
+
new Error(
|
|
699
|
+
`Session "${session.name}" timed out after ${options.timeout}ms`
|
|
700
|
+
)
|
|
701
|
+
),
|
|
702
|
+
options.timeout
|
|
703
|
+
)
|
|
704
|
+
);
|
|
705
|
+
try {
|
|
706
|
+
result = await Promise.race([execPromise, timeoutPromise]);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
result = {
|
|
709
|
+
findings: [],
|
|
710
|
+
stepsExecuted: 0,
|
|
711
|
+
payloadsTested: 0,
|
|
712
|
+
duration: options.timeout,
|
|
713
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
execPromise.catch(() => {
|
|
717
|
+
});
|
|
718
|
+
} else {
|
|
719
|
+
try {
|
|
720
|
+
result = await this.execute(session, pluginManager2, sessionOptions);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
result = {
|
|
723
|
+
findings: [],
|
|
724
|
+
stepsExecuted: 0,
|
|
725
|
+
payloadsTested: 0,
|
|
726
|
+
duration: 0,
|
|
727
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
}
|
|
315
731
|
results.push(result);
|
|
316
732
|
allFindings.push(...result.findings);
|
|
317
733
|
totalSteps += result.stepsExecuted;
|
|
318
734
|
totalPayloads += result.payloadsTested;
|
|
319
735
|
allErrors.push(...result.errors);
|
|
736
|
+
options.onSessionEnd?.(session, result, i, sessions.length);
|
|
320
737
|
}
|
|
321
738
|
} finally {
|
|
322
739
|
if (sharedBrowser && typeof sharedBrowser.close === "function") {
|
|
@@ -389,124 +806,29 @@ var driverManager = new DriverManager();
|
|
|
389
806
|
var DRIVER_API_VERSION = 1;
|
|
390
807
|
|
|
391
808
|
// src/plugin-manager.ts
|
|
392
|
-
import {
|
|
393
|
-
import { existsSync } from "fs";
|
|
394
|
-
import { resolve as resolve2, isAbsolute as isAbsolute2 } from "path";
|
|
395
|
-
import YAML from "yaml";
|
|
396
|
-
import { z } from "zod";
|
|
809
|
+
import { createRequire as createRequire2 } from "module";
|
|
397
810
|
|
|
398
811
|
// src/plugin-types.ts
|
|
399
812
|
var PLUGIN_API_VERSION = 1;
|
|
400
813
|
|
|
401
814
|
// src/plugin-manager.ts
|
|
402
|
-
var
|
|
403
|
-
var
|
|
404
|
-
|
|
405
|
-
plugins: z.array(
|
|
406
|
-
z.object({
|
|
407
|
-
name: z.string(),
|
|
408
|
-
config: z.record(z.unknown()).optional(),
|
|
409
|
-
enabled: z.boolean().default(true)
|
|
410
|
-
})
|
|
411
|
-
).optional(),
|
|
412
|
-
settings: z.object({
|
|
413
|
-
browser: z.enum(["chromium", "firefox", "webkit"]).optional(),
|
|
414
|
-
headless: z.boolean().optional(),
|
|
415
|
-
timeout: z.number().optional()
|
|
416
|
-
}).optional()
|
|
417
|
-
});
|
|
418
|
-
var PluginManager = class {
|
|
815
|
+
var _require = createRequire2(import.meta.url);
|
|
816
|
+
var { version: ENGINE_VERSION2 } = _require("../package.json");
|
|
817
|
+
var PluginManager = class _PluginManager {
|
|
419
818
|
plugins = [];
|
|
420
|
-
config = null;
|
|
421
819
|
initialized = false;
|
|
820
|
+
errorHandler;
|
|
422
821
|
/**
|
|
423
822
|
* Shared context passed to all plugins
|
|
424
823
|
*/
|
|
425
824
|
sharedPayloads = [];
|
|
426
825
|
sharedFindings = [];
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
*/
|
|
430
|
-
async loadConfig(configPath) {
|
|
431
|
-
const paths = configPath ? [configPath] : [
|
|
432
|
-
"vulcn.config.yml",
|
|
433
|
-
"vulcn.config.yaml",
|
|
434
|
-
"vulcn.config.json",
|
|
435
|
-
".vulcnrc.yml",
|
|
436
|
-
".vulcnrc.yaml",
|
|
437
|
-
".vulcnrc.json"
|
|
438
|
-
];
|
|
439
|
-
for (const path of paths) {
|
|
440
|
-
const resolved = isAbsolute2(path) ? path : resolve2(process.cwd(), path);
|
|
441
|
-
if (existsSync(resolved)) {
|
|
442
|
-
const content = await readFile(resolved, "utf-8");
|
|
443
|
-
const parsed = path.endsWith(".json") ? JSON.parse(content) : YAML.parse(content);
|
|
444
|
-
this.config = VulcnConfigSchema.parse(parsed);
|
|
445
|
-
return this.config;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
this.config = { version: "1", plugins: [], settings: {} };
|
|
449
|
-
return this.config;
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Load all plugins from config
|
|
453
|
-
*/
|
|
454
|
-
async loadPlugins() {
|
|
455
|
-
if (!this.config) {
|
|
456
|
-
await this.loadConfig();
|
|
457
|
-
}
|
|
458
|
-
const pluginConfigs = this.config?.plugins || [];
|
|
459
|
-
for (const pluginConfig of pluginConfigs) {
|
|
460
|
-
if (pluginConfig.enabled === false) continue;
|
|
461
|
-
try {
|
|
462
|
-
const loaded = await this.loadPlugin(pluginConfig);
|
|
463
|
-
this.plugins.push(loaded);
|
|
464
|
-
} catch (err) {
|
|
465
|
-
console.error(
|
|
466
|
-
`Failed to load plugin ${pluginConfig.name}:`,
|
|
467
|
-
err instanceof Error ? err.message : String(err)
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
826
|
+
constructor(errorHandler) {
|
|
827
|
+
this.errorHandler = errorHandler ?? new ErrorHandler();
|
|
471
828
|
}
|
|
472
|
-
/**
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
async loadPlugin(config) {
|
|
476
|
-
const { name, config: pluginConfig = {} } = config;
|
|
477
|
-
let plugin;
|
|
478
|
-
let source;
|
|
479
|
-
if (name.startsWith("./") || name.startsWith("../") || isAbsolute2(name)) {
|
|
480
|
-
const resolved = isAbsolute2(name) ? name : resolve2(process.cwd(), name);
|
|
481
|
-
const module = await import(resolved);
|
|
482
|
-
plugin = module.default || module;
|
|
483
|
-
source = "local";
|
|
484
|
-
} else if (name.startsWith("@vulcn/")) {
|
|
485
|
-
const module = await import(name);
|
|
486
|
-
plugin = module.default || module;
|
|
487
|
-
source = "npm";
|
|
488
|
-
} else {
|
|
489
|
-
const module = await import(name);
|
|
490
|
-
plugin = module.default || module;
|
|
491
|
-
source = "npm";
|
|
492
|
-
}
|
|
493
|
-
this.validatePlugin(plugin);
|
|
494
|
-
let resolvedConfig = pluginConfig;
|
|
495
|
-
if (plugin.configSchema) {
|
|
496
|
-
try {
|
|
497
|
-
resolvedConfig = plugin.configSchema.parse(pluginConfig);
|
|
498
|
-
} catch (err) {
|
|
499
|
-
throw new Error(
|
|
500
|
-
`Invalid config for plugin ${name}: ${err instanceof Error ? err.message : String(err)}`
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
return {
|
|
505
|
-
plugin,
|
|
506
|
-
config: resolvedConfig,
|
|
507
|
-
source,
|
|
508
|
-
enabled: true
|
|
509
|
-
};
|
|
829
|
+
/** Get the error handler for post-run inspection */
|
|
830
|
+
getErrorHandler() {
|
|
831
|
+
return this.errorHandler;
|
|
510
832
|
}
|
|
511
833
|
/**
|
|
512
834
|
* Validate plugin structure
|
|
@@ -565,6 +887,120 @@ var PluginManager = class {
|
|
|
565
887
|
this.sharedFindings = [];
|
|
566
888
|
this.initialized = false;
|
|
567
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Load the engine from a flat VulcnProjectConfig (from `.vulcn.yml`).
|
|
892
|
+
*
|
|
893
|
+
* This is the primary entry point for the new config system.
|
|
894
|
+
* Maps user-facing config keys to internal plugin configs automatically.
|
|
895
|
+
*
|
|
896
|
+
* @param config - Parsed and validated VulcnProjectConfig
|
|
897
|
+
*/
|
|
898
|
+
async loadFromConfig(config) {
|
|
899
|
+
const { payloads, detection } = config;
|
|
900
|
+
if (payloads.custom) {
|
|
901
|
+
try {
|
|
902
|
+
const payloadPkg = "@vulcn/plugin-payloads";
|
|
903
|
+
const { loadFromFile } = await import(
|
|
904
|
+
/* @vite-ignore */
|
|
905
|
+
payloadPkg
|
|
906
|
+
);
|
|
907
|
+
const loaded = await loadFromFile(payloads.custom);
|
|
908
|
+
this.addPayloads(loaded);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`Failed to load custom payloads from ${payloads.custom}: ${err instanceof Error ? err.message : String(err)}`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
const payloadPkg = "@vulcn/plugin-payloads";
|
|
917
|
+
const { getCuratedPayloads, loadPayloadBox } = await import(
|
|
918
|
+
/* @vite-ignore */
|
|
919
|
+
payloadPkg
|
|
920
|
+
);
|
|
921
|
+
for (const name of payloads.types) {
|
|
922
|
+
const curated = getCuratedPayloads(name);
|
|
923
|
+
if (curated) {
|
|
924
|
+
this.addPayloads(curated);
|
|
925
|
+
}
|
|
926
|
+
if (payloads.payloadbox || !curated) {
|
|
927
|
+
try {
|
|
928
|
+
const payload = await loadPayloadBox(name, payloads.limit);
|
|
929
|
+
this.addPayloads([payload]);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
if (!curated) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`No payloads for "${name}": no curated set and PayloadBox failed: ${err instanceof Error ? err.message : String(err)}`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
} catch (err) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
`Failed to load payloads: ${err instanceof Error ? err.message : String(err)}`
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (payloads.types.includes("xss") && !this.hasPlugin("@vulcn/plugin-detect-xss")) {
|
|
945
|
+
try {
|
|
946
|
+
const pkg = "@vulcn/plugin-detect-xss";
|
|
947
|
+
const mod = await import(
|
|
948
|
+
/* @vite-ignore */
|
|
949
|
+
pkg
|
|
950
|
+
);
|
|
951
|
+
this.addPlugin(mod.default, {
|
|
952
|
+
detectDialogs: detection.xss.dialogs,
|
|
953
|
+
detectConsole: detection.xss.console,
|
|
954
|
+
consoleMarker: detection.xss.consoleMarker,
|
|
955
|
+
detectDomMutation: detection.xss.domMutation,
|
|
956
|
+
severity: detection.xss.severity,
|
|
957
|
+
alertPatterns: detection.xss.alertPatterns
|
|
958
|
+
});
|
|
959
|
+
} catch (err) {
|
|
960
|
+
this.errorHandler.catch(err, {
|
|
961
|
+
severity: "warn" /* WARN */,
|
|
962
|
+
source: "plugin-manager:loadFromConfig",
|
|
963
|
+
context: { plugin: "@vulcn/plugin-detect-xss" }
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const hasSqli = payloads.types.some((t) => {
|
|
968
|
+
const lower = t.toLowerCase();
|
|
969
|
+
return lower === "sqli" || lower.includes("sql");
|
|
970
|
+
});
|
|
971
|
+
if (hasSqli && !this.hasPlugin("@vulcn/plugin-detect-sqli")) {
|
|
972
|
+
try {
|
|
973
|
+
const pkg = "@vulcn/plugin-detect-sqli";
|
|
974
|
+
const mod = await import(
|
|
975
|
+
/* @vite-ignore */
|
|
976
|
+
pkg
|
|
977
|
+
);
|
|
978
|
+
this.addPlugin(mod.default);
|
|
979
|
+
} catch (err) {
|
|
980
|
+
this.errorHandler.catch(err, {
|
|
981
|
+
severity: "warn" /* WARN */,
|
|
982
|
+
source: "plugin-manager:loadFromConfig",
|
|
983
|
+
context: { plugin: "@vulcn/plugin-detect-sqli" }
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (detection.passive && !this.hasPlugin("@vulcn/plugin-passive")) {
|
|
988
|
+
try {
|
|
989
|
+
const pkg = "@vulcn/plugin-passive";
|
|
990
|
+
const mod = await import(
|
|
991
|
+
/* @vite-ignore */
|
|
992
|
+
pkg
|
|
993
|
+
);
|
|
994
|
+
this.addPlugin(mod.default);
|
|
995
|
+
} catch (err) {
|
|
996
|
+
this.errorHandler.catch(err, {
|
|
997
|
+
severity: "warn" /* WARN */,
|
|
998
|
+
source: "plugin-manager:loadFromConfig",
|
|
999
|
+
context: { plugin: "@vulcn/plugin-passive" }
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
568
1004
|
/**
|
|
569
1005
|
* Get all loaded payloads
|
|
570
1006
|
*/
|
|
@@ -610,9 +1046,9 @@ var PluginManager = class {
|
|
|
610
1046
|
/**
|
|
611
1047
|
* Create base context for plugins
|
|
612
1048
|
*/
|
|
613
|
-
createContext(pluginConfig) {
|
|
1049
|
+
createContext(pluginConfig, pluginName) {
|
|
614
1050
|
const engineInfo = {
|
|
615
|
-
version:
|
|
1051
|
+
version: ENGINE_VERSION2,
|
|
616
1052
|
pluginApiVersion: PLUGIN_API_VERSION
|
|
617
1053
|
};
|
|
618
1054
|
return {
|
|
@@ -621,9 +1057,13 @@ var PluginManager = class {
|
|
|
621
1057
|
payloads: this.sharedPayloads,
|
|
622
1058
|
findings: this.sharedFindings,
|
|
623
1059
|
addFinding: (finding) => {
|
|
1060
|
+
console.log(
|
|
1061
|
+
`[DEBUG-PM] Plugin ${pluginName || "?"} adding finding: ${finding.type}`
|
|
1062
|
+
);
|
|
624
1063
|
this.sharedFindings.push(finding);
|
|
625
1064
|
},
|
|
626
|
-
logger: this.createLogger("plugin"),
|
|
1065
|
+
logger: this.createLogger(pluginName || "plugin"),
|
|
1066
|
+
errors: this.errorHandler,
|
|
627
1067
|
fetch: globalThis.fetch
|
|
628
1068
|
};
|
|
629
1069
|
}
|
|
@@ -639,6 +1079,26 @@ var PluginManager = class {
|
|
|
639
1079
|
error: (msg, ...args) => console.error(prefix, msg, ...args)
|
|
640
1080
|
};
|
|
641
1081
|
}
|
|
1082
|
+
// ── Hook severity classification ────────────────────────────────────
|
|
1083
|
+
//
|
|
1084
|
+
// Hooks that produce OUTPUT (reports, results) are FATAL on failure.
|
|
1085
|
+
// Hooks that set up state are ERROR. Everything else is WARN.
|
|
1086
|
+
//
|
|
1087
|
+
static FATAL_HOOKS = /* @__PURE__ */ new Set([
|
|
1088
|
+
"onRunEnd",
|
|
1089
|
+
"onScanEnd"
|
|
1090
|
+
]);
|
|
1091
|
+
static ERROR_HOOKS = /* @__PURE__ */ new Set([
|
|
1092
|
+
"onInit",
|
|
1093
|
+
"onRunStart",
|
|
1094
|
+
"onScanStart",
|
|
1095
|
+
"onAfterPayload"
|
|
1096
|
+
]);
|
|
1097
|
+
hookSeverity(hookName) {
|
|
1098
|
+
if (_PluginManager.FATAL_HOOKS.has(hookName)) return "fatal" /* FATAL */;
|
|
1099
|
+
if (_PluginManager.ERROR_HOOKS.has(hookName)) return "error" /* ERROR */;
|
|
1100
|
+
return "warn" /* WARN */;
|
|
1101
|
+
}
|
|
642
1102
|
/**
|
|
643
1103
|
* Call a hook on all plugins sequentially
|
|
644
1104
|
*/
|
|
@@ -646,15 +1106,16 @@ var PluginManager = class {
|
|
|
646
1106
|
for (const loaded of this.plugins) {
|
|
647
1107
|
const hook = loaded.plugin.hooks?.[hookName];
|
|
648
1108
|
if (hook) {
|
|
649
|
-
const ctx = this.createContext(loaded.config);
|
|
1109
|
+
const ctx = this.createContext(loaded.config, loaded.plugin.name);
|
|
650
1110
|
ctx.logger = this.createLogger(loaded.plugin.name);
|
|
651
1111
|
try {
|
|
652
1112
|
await executor(hook, ctx);
|
|
653
1113
|
} catch (err) {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1114
|
+
this.errorHandler.catch(err, {
|
|
1115
|
+
severity: this.hookSeverity(hookName),
|
|
1116
|
+
source: `plugin:${loaded.plugin.name}`,
|
|
1117
|
+
context: { hook: hookName }
|
|
1118
|
+
});
|
|
658
1119
|
}
|
|
659
1120
|
}
|
|
660
1121
|
}
|
|
@@ -667,7 +1128,7 @@ var PluginManager = class {
|
|
|
667
1128
|
for (const loaded of this.plugins) {
|
|
668
1129
|
const hook = loaded.plugin.hooks?.[hookName];
|
|
669
1130
|
if (hook) {
|
|
670
|
-
const ctx = this.createContext(loaded.config);
|
|
1131
|
+
const ctx = this.createContext(loaded.config, loaded.plugin.name);
|
|
671
1132
|
ctx.logger = this.createLogger(loaded.plugin.name);
|
|
672
1133
|
try {
|
|
673
1134
|
const result = await executor(
|
|
@@ -682,10 +1143,11 @@ var PluginManager = class {
|
|
|
682
1143
|
}
|
|
683
1144
|
}
|
|
684
1145
|
} catch (err) {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1146
|
+
this.errorHandler.catch(err, {
|
|
1147
|
+
severity: this.hookSeverity(hookName),
|
|
1148
|
+
source: `plugin:${loaded.plugin.name}`,
|
|
1149
|
+
context: { hook: hookName }
|
|
1150
|
+
});
|
|
689
1151
|
}
|
|
690
1152
|
}
|
|
691
1153
|
}
|
|
@@ -699,7 +1161,7 @@ var PluginManager = class {
|
|
|
699
1161
|
for (const loaded of this.plugins) {
|
|
700
1162
|
const hook = loaded.plugin.hooks?.[hookName];
|
|
701
1163
|
if (hook) {
|
|
702
|
-
const ctx = this.createContext(loaded.config);
|
|
1164
|
+
const ctx = this.createContext(loaded.config, loaded.plugin.name);
|
|
703
1165
|
ctx.logger = this.createLogger(loaded.plugin.name);
|
|
704
1166
|
try {
|
|
705
1167
|
value = await executor(
|
|
@@ -708,10 +1170,11 @@ var PluginManager = class {
|
|
|
708
1170
|
ctx
|
|
709
1171
|
);
|
|
710
1172
|
} catch (err) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1173
|
+
this.errorHandler.catch(err, {
|
|
1174
|
+
severity: this.hookSeverity(hookName),
|
|
1175
|
+
source: `plugin:${loaded.plugin.name}`,
|
|
1176
|
+
context: { hook: hookName }
|
|
1177
|
+
});
|
|
715
1178
|
}
|
|
716
1179
|
}
|
|
717
1180
|
}
|
|
@@ -797,177 +1260,40 @@ function getPassphrase(interactive) {
|
|
|
797
1260
|
);
|
|
798
1261
|
}
|
|
799
1262
|
|
|
800
|
-
// src/
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1263
|
+
// src/payload-types.ts
|
|
1264
|
+
function getSeverity(category) {
|
|
1265
|
+
switch (category) {
|
|
1266
|
+
case "sqli":
|
|
1267
|
+
case "command-injection":
|
|
1268
|
+
case "xxe":
|
|
1269
|
+
return "critical";
|
|
1270
|
+
case "xss":
|
|
1271
|
+
case "ssrf":
|
|
1272
|
+
case "path-traversal":
|
|
1273
|
+
return "high";
|
|
1274
|
+
case "open-redirect":
|
|
1275
|
+
return "medium";
|
|
1276
|
+
case "security-misconfiguration":
|
|
1277
|
+
return "low";
|
|
1278
|
+
case "information-disclosure":
|
|
1279
|
+
return "info";
|
|
1280
|
+
default:
|
|
1281
|
+
return "medium";
|
|
811
1282
|
}
|
|
812
|
-
const manifestYaml = await readFile2(manifestPath, "utf-8");
|
|
813
|
-
const manifest = parse2(manifestYaml);
|
|
814
|
-
if (manifest.version !== "2") {
|
|
815
|
-
throw new Error(
|
|
816
|
-
`Unsupported session format version: ${manifest.version}. Expected "2".`
|
|
817
|
-
);
|
|
818
|
-
}
|
|
819
|
-
let authConfig;
|
|
820
|
-
if (manifest.auth?.configFile) {
|
|
821
|
-
const authPath = join(dirPath, manifest.auth.configFile);
|
|
822
|
-
if (existsSync2(authPath)) {
|
|
823
|
-
const authYaml = await readFile2(authPath, "utf-8");
|
|
824
|
-
authConfig = parse2(authYaml);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
const sessions = [];
|
|
828
|
-
for (const ref of manifest.sessions) {
|
|
829
|
-
if (ref.injectable === false) continue;
|
|
830
|
-
const sessionPath = join(dirPath, ref.file);
|
|
831
|
-
if (!existsSync2(sessionPath)) {
|
|
832
|
-
console.warn(`Session file not found: ${sessionPath}, skipping`);
|
|
833
|
-
continue;
|
|
834
|
-
}
|
|
835
|
-
const sessionYaml = await readFile2(sessionPath, "utf-8");
|
|
836
|
-
const sessionData = parse2(sessionYaml);
|
|
837
|
-
const session = {
|
|
838
|
-
name: sessionData.name ?? basename(ref.file, ".yml"),
|
|
839
|
-
driver: manifest.driver,
|
|
840
|
-
driverConfig: {
|
|
841
|
-
...manifest.driverConfig,
|
|
842
|
-
startUrl: resolveUrl(
|
|
843
|
-
manifest.target,
|
|
844
|
-
sessionData.page
|
|
845
|
-
)
|
|
846
|
-
},
|
|
847
|
-
steps: sessionData.steps ?? [],
|
|
848
|
-
metadata: {
|
|
849
|
-
recordedAt: manifest.recordedAt,
|
|
850
|
-
version: "2",
|
|
851
|
-
manifestDir: dirPath
|
|
852
|
-
}
|
|
853
|
-
};
|
|
854
|
-
sessions.push(session);
|
|
855
|
-
}
|
|
856
|
-
return { manifest, sessions, authConfig };
|
|
857
|
-
}
|
|
858
|
-
function isSessionDir(path) {
|
|
859
|
-
return existsSync2(join(path, "manifest.yml"));
|
|
860
|
-
}
|
|
861
|
-
function looksLikeSessionDir(path) {
|
|
862
|
-
return path.endsWith(".vulcn") || path.endsWith(".vulcn/");
|
|
863
|
-
}
|
|
864
|
-
async function saveSessionDir(dirPath, options) {
|
|
865
|
-
await mkdir(join(dirPath, "sessions"), { recursive: true });
|
|
866
|
-
const sessionRefs = [];
|
|
867
|
-
for (const session of options.sessions) {
|
|
868
|
-
const safeName = slugify(session.name);
|
|
869
|
-
const fileName = `sessions/${safeName}.yml`;
|
|
870
|
-
const sessionPath = join(dirPath, fileName);
|
|
871
|
-
const startUrl = session.driverConfig.startUrl;
|
|
872
|
-
const page = startUrl ? startUrl.replace(options.target, "").replace(/^\//, "/") : void 0;
|
|
873
|
-
const sessionData = {
|
|
874
|
-
name: session.name,
|
|
875
|
-
...page ? { page } : {},
|
|
876
|
-
steps: session.steps
|
|
877
|
-
};
|
|
878
|
-
await writeFile(sessionPath, stringify2(sessionData), "utf-8");
|
|
879
|
-
const hasInjectable = session.steps.some(
|
|
880
|
-
(s) => s.type === "browser.input" && s.injectable !== false
|
|
881
|
-
);
|
|
882
|
-
sessionRefs.push({
|
|
883
|
-
file: fileName,
|
|
884
|
-
injectable: hasInjectable
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
if (options.authConfig) {
|
|
888
|
-
await mkdir(join(dirPath, "auth"), { recursive: true });
|
|
889
|
-
await writeFile(
|
|
890
|
-
join(dirPath, "auth", "config.yml"),
|
|
891
|
-
stringify2(options.authConfig),
|
|
892
|
-
"utf-8"
|
|
893
|
-
);
|
|
894
|
-
}
|
|
895
|
-
if (options.encryptedState) {
|
|
896
|
-
await mkdir(join(dirPath, "auth"), { recursive: true });
|
|
897
|
-
await writeFile(
|
|
898
|
-
join(dirPath, "auth", "state.enc"),
|
|
899
|
-
options.encryptedState,
|
|
900
|
-
"utf-8"
|
|
901
|
-
);
|
|
902
|
-
}
|
|
903
|
-
if (options.requests && options.requests.length > 0) {
|
|
904
|
-
await mkdir(join(dirPath, "requests"), { recursive: true });
|
|
905
|
-
for (const req of options.requests) {
|
|
906
|
-
const safeName = slugify(req.sessionName);
|
|
907
|
-
await writeFile(
|
|
908
|
-
join(dirPath, "requests", `${safeName}.json`),
|
|
909
|
-
JSON.stringify(req, null, 2),
|
|
910
|
-
"utf-8"
|
|
911
|
-
);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
const manifest = {
|
|
915
|
-
version: "2",
|
|
916
|
-
name: options.name,
|
|
917
|
-
target: options.target,
|
|
918
|
-
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
919
|
-
driver: options.driver,
|
|
920
|
-
driverConfig: options.driverConfig,
|
|
921
|
-
...options.authConfig ? {
|
|
922
|
-
auth: {
|
|
923
|
-
strategy: options.authConfig.strategy,
|
|
924
|
-
configFile: "auth/config.yml",
|
|
925
|
-
stateFile: options.encryptedState ? "auth/state.enc" : void 0,
|
|
926
|
-
loggedInIndicator: options.authConfig.loggedInIndicator,
|
|
927
|
-
loggedOutIndicator: options.authConfig.loggedOutIndicator
|
|
928
|
-
}
|
|
929
|
-
} : {},
|
|
930
|
-
sessions: sessionRefs,
|
|
931
|
-
scan: {
|
|
932
|
-
tier: "auto",
|
|
933
|
-
parallel: 1,
|
|
934
|
-
timeout: 12e4
|
|
935
|
-
}
|
|
936
|
-
};
|
|
937
|
-
await writeFile(join(dirPath, "manifest.yml"), stringify2(manifest), "utf-8");
|
|
938
|
-
}
|
|
939
|
-
async function readAuthState(dirPath) {
|
|
940
|
-
const statePath = join(dirPath, "auth", "state.enc");
|
|
941
|
-
if (!existsSync2(statePath)) return null;
|
|
942
|
-
return readFile2(statePath, "utf-8");
|
|
943
|
-
}
|
|
944
|
-
async function readCapturedRequests(dirPath) {
|
|
945
|
-
const requestsDir = join(dirPath, "requests");
|
|
946
|
-
if (!existsSync2(requestsDir)) return [];
|
|
947
|
-
const files = await readdir(requestsDir);
|
|
948
|
-
const requests = [];
|
|
949
|
-
for (const file of files) {
|
|
950
|
-
if (!file.endsWith(".json")) continue;
|
|
951
|
-
const content = await readFile2(join(requestsDir, file), "utf-8");
|
|
952
|
-
requests.push(JSON.parse(content));
|
|
953
|
-
}
|
|
954
|
-
return requests;
|
|
955
|
-
}
|
|
956
|
-
function resolveUrl(target, page) {
|
|
957
|
-
if (!page) return target;
|
|
958
|
-
if (page.startsWith("http")) return page;
|
|
959
|
-
const base = target.replace(/\/$/, "");
|
|
960
|
-
const path = page.startsWith("/") ? page : `/${page}`;
|
|
961
|
-
return `${base}${path}`;
|
|
962
|
-
}
|
|
963
|
-
function slugify(text) {
|
|
964
|
-
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
965
1283
|
}
|
|
966
1284
|
export {
|
|
1285
|
+
CONFIG_FILENAME,
|
|
1286
|
+
DEFAULT_PROJECT_CONFIG,
|
|
1287
|
+
DIRS,
|
|
967
1288
|
DRIVER_API_VERSION,
|
|
968
1289
|
DriverManager,
|
|
1290
|
+
ENGINE_VERSION,
|
|
1291
|
+
ErrorHandler,
|
|
1292
|
+
ErrorSeverity,
|
|
969
1293
|
PLUGIN_API_VERSION,
|
|
970
1294
|
PluginManager,
|
|
1295
|
+
VulcnError,
|
|
1296
|
+
VulcnProjectConfigSchema,
|
|
971
1297
|
decrypt,
|
|
972
1298
|
decryptCredentials,
|
|
973
1299
|
decryptStorageState,
|
|
@@ -975,13 +1301,17 @@ export {
|
|
|
975
1301
|
encrypt,
|
|
976
1302
|
encryptCredentials,
|
|
977
1303
|
encryptStorageState,
|
|
1304
|
+
ensureProjectDirs,
|
|
1305
|
+
error,
|
|
1306
|
+
fatal,
|
|
1307
|
+
findProjectRoot,
|
|
978
1308
|
getPassphrase,
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1309
|
+
getSeverity,
|
|
1310
|
+
loadProject,
|
|
1311
|
+
loadProjectFromFile,
|
|
1312
|
+
parseProjectConfig,
|
|
982
1313
|
pluginManager,
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
saveSessionDir
|
|
1314
|
+
resolveProjectPaths,
|
|
1315
|
+
warn
|
|
986
1316
|
};
|
|
987
1317
|
//# sourceMappingURL=index.js.map
|