@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/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 : resolve(process.cwd(), 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
- * Handles both new driver-format sessions and legacy v1 sessions.
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, defaultDriver = "browser") {
481
+ parseSession(yaml) {
94
482
  const data = parse(yaml);
95
- if (data.driver && typeof data.driver === "string") {
96
- return data;
97
- }
98
- const steps = data.steps ?? [];
99
- const convertedSteps = steps.map((step) => {
100
- const type = step.type;
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: "0.3.0", pluginApiVersion: 1 },
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
- logger.warn(
205
- `Plugin ${loaded.plugin.name} onRunStart failed: ${err}`
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
- logger.warn(
222
- `Plugin ${loaded.plugin.name} onBeforeClose failed: ${err}`
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
- logger.warn(`Plugin ${loaded.plugin.name} onRunEnd failed: ${err}`);
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.name === "browser") {
654
+ if (typeof firstDriver.createSharedResource === "function") {
279
655
  try {
280
- const driverPkg = "@vulcn/driver-browser";
281
- const { launchBrowser } = await import(
282
- /* @vite-ignore */
283
- driverPkg
656
+ const driverConfig = sessions[0].driverConfig;
657
+ sharedBrowser = await firstDriver.createSharedResource(
658
+ driverConfig,
659
+ options
284
660
  );
285
- const browserType = sessions[0].driverConfig.browser ?? "chromium";
286
- const headless = options.headless ?? true;
287
- const result = await launchBrowser({
288
- browser: browserType,
289
- headless
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 (const session of sessions) {
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
- const result = await this.execute(
311
- session,
312
- pluginManager2,
313
- sessionOptions
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 { readFile } from "fs/promises";
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 ENGINE_VERSION = "0.2.0";
403
- var VulcnConfigSchema = z.object({
404
- version: z.string().default("1"),
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
- * Load configuration from vulcn.config.yml
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
- * Load a single plugin
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: ENGINE_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
- console.error(
655
- `Error in plugin ${loaded.plugin.name}.${hookName}:`,
656
- err instanceof Error ? err.message : String(err)
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
- console.error(
686
- `Error in plugin ${loaded.plugin.name}.${hookName}:`,
687
- err instanceof Error ? err.message : String(err)
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
- console.error(
712
- `Error in plugin ${loaded.plugin.name}.${hookName}:`,
713
- err instanceof Error ? err.message : String(err)
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/session.ts
801
- import { readFile as readFile2, writeFile, mkdir, readdir } from "fs/promises";
802
- import { existsSync as existsSync2 } from "fs";
803
- import { join, basename } from "path";
804
- import { parse as parse2, stringify as stringify2 } from "yaml";
805
- async function loadSessionDir(dirPath) {
806
- const manifestPath = join(dirPath, "manifest.yml");
807
- if (!existsSync2(manifestPath)) {
808
- throw new Error(
809
- `No manifest.yml found in ${dirPath}. Is this a v2 session directory?`
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
- isSessionDir,
980
- loadSessionDir,
981
- looksLikeSessionDir,
1309
+ getSeverity,
1310
+ loadProject,
1311
+ loadProjectFromFile,
1312
+ parseProjectConfig,
982
1313
  pluginManager,
983
- readAuthState,
984
- readCapturedRequests,
985
- saveSessionDir
1314
+ resolveProjectPaths,
1315
+ warn
986
1316
  };
987
1317
  //# sourceMappingURL=index.js.map