@vulcn/engine 0.1.0 → 0.3.0

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,60 +1,543 @@
1
- // session.ts
1
+ // src/driver-manager.ts
2
+ import { isAbsolute, resolve } from "path";
3
+ var DriverManager = class {
4
+ drivers = /* @__PURE__ */ new Map();
5
+ defaultDriver = null;
6
+ /**
7
+ * Register a driver
8
+ */
9
+ register(driver, source = "builtin") {
10
+ this.validateDriver(driver);
11
+ this.drivers.set(driver.name, { driver, source });
12
+ if (this.drivers.size === 1) {
13
+ this.defaultDriver = driver.name;
14
+ }
15
+ }
16
+ /**
17
+ * Load a driver from npm or local path
18
+ */
19
+ async load(nameOrPath) {
20
+ let driver;
21
+ let source;
22
+ if (nameOrPath.startsWith("./") || nameOrPath.startsWith("../") || isAbsolute(nameOrPath)) {
23
+ const resolved = isAbsolute(nameOrPath) ? nameOrPath : resolve(process.cwd(), nameOrPath);
24
+ const module = await import(resolved);
25
+ driver = module.default || module;
26
+ source = "local";
27
+ } else {
28
+ const module = await import(nameOrPath);
29
+ driver = module.default || module;
30
+ source = "npm";
31
+ }
32
+ this.register(driver, source);
33
+ }
34
+ /**
35
+ * Get a loaded driver by name
36
+ */
37
+ get(name) {
38
+ return this.drivers.get(name)?.driver;
39
+ }
40
+ /**
41
+ * Get the default driver
42
+ */
43
+ getDefault() {
44
+ if (!this.defaultDriver) return void 0;
45
+ return this.get(this.defaultDriver);
46
+ }
47
+ /**
48
+ * Set the default driver
49
+ */
50
+ setDefault(name) {
51
+ if (!this.drivers.has(name)) {
52
+ throw new Error(`Driver "${name}" is not registered`);
53
+ }
54
+ this.defaultDriver = name;
55
+ }
56
+ /**
57
+ * Check if a driver is registered
58
+ */
59
+ has(name) {
60
+ return this.drivers.has(name);
61
+ }
62
+ /**
63
+ * Get all registered drivers
64
+ */
65
+ list() {
66
+ return Array.from(this.drivers.values());
67
+ }
68
+ /**
69
+ * Get driver for a session
70
+ */
71
+ getForSession(session) {
72
+ const driverName = session.driver;
73
+ const driver = this.get(driverName);
74
+ if (!driver) {
75
+ throw new Error(
76
+ `Driver "${driverName}" not found. Install @vulcn/driver-${driverName} or load it manually.`
77
+ );
78
+ }
79
+ return driver;
80
+ }
81
+ /**
82
+ * Start recording with a driver
83
+ */
84
+ async startRecording(driverName, config, options = {}) {
85
+ const driver = this.get(driverName);
86
+ if (!driver) {
87
+ throw new Error(`Driver "${driverName}" not found`);
88
+ }
89
+ return driver.recorder.start(config, options);
90
+ }
91
+ /**
92
+ * Execute a session
93
+ */
94
+ async execute(session, pluginManager2, options = {}) {
95
+ const driver = this.getForSession(session);
96
+ const findings = [];
97
+ const logger = this.createLogger(driver.name);
98
+ const ctx = {
99
+ session,
100
+ pluginManager: pluginManager2,
101
+ payloads: pluginManager2.getPayloads(),
102
+ findings,
103
+ addFinding: (finding) => {
104
+ findings.push(finding);
105
+ pluginManager2.addFinding(finding);
106
+ options.onFinding?.(finding);
107
+ },
108
+ logger,
109
+ options
110
+ };
111
+ return driver.runner.execute(session, ctx);
112
+ }
113
+ /**
114
+ * Validate driver structure
115
+ */
116
+ validateDriver(driver) {
117
+ if (!driver || typeof driver !== "object") {
118
+ throw new Error("Driver must be an object");
119
+ }
120
+ const d = driver;
121
+ if (typeof d.name !== "string" || !d.name) {
122
+ throw new Error("Driver must have a name");
123
+ }
124
+ if (typeof d.version !== "string" || !d.version) {
125
+ throw new Error("Driver must have a version");
126
+ }
127
+ if (!Array.isArray(d.stepTypes) || d.stepTypes.length === 0) {
128
+ throw new Error("Driver must define stepTypes");
129
+ }
130
+ if (!d.recorder || typeof d.recorder !== "object") {
131
+ throw new Error("Driver must have a recorder");
132
+ }
133
+ if (!d.runner || typeof d.runner !== "object") {
134
+ throw new Error("Driver must have a runner");
135
+ }
136
+ }
137
+ /**
138
+ * Create a scoped logger for a driver
139
+ */
140
+ createLogger(name) {
141
+ const prefix = `[driver:${name}]`;
142
+ return {
143
+ debug: (msg, ...args) => console.debug(prefix, msg, ...args),
144
+ info: (msg, ...args) => console.info(prefix, msg, ...args),
145
+ warn: (msg, ...args) => console.warn(prefix, msg, ...args),
146
+ error: (msg, ...args) => console.error(prefix, msg, ...args)
147
+ };
148
+ }
149
+ };
150
+ var driverManager = new DriverManager();
151
+
152
+ // src/driver-types.ts
153
+ var DRIVER_API_VERSION = 1;
154
+
155
+ // src/plugin-manager.ts
156
+ import { readFile } from "fs/promises";
157
+ import { existsSync } from "fs";
158
+ import { resolve as resolve2, isAbsolute as isAbsolute2 } from "path";
159
+ import YAML from "yaml";
2
160
  import { z } from "zod";
161
+
162
+ // src/plugin-types.ts
163
+ var PLUGIN_API_VERSION = 1;
164
+
165
+ // src/plugin-manager.ts
166
+ var ENGINE_VERSION = "0.2.0";
167
+ var VulcnConfigSchema = z.object({
168
+ version: z.string().default("1"),
169
+ plugins: z.array(
170
+ z.object({
171
+ name: z.string(),
172
+ config: z.record(z.unknown()).optional(),
173
+ enabled: z.boolean().default(true)
174
+ })
175
+ ).optional(),
176
+ settings: z.object({
177
+ browser: z.enum(["chromium", "firefox", "webkit"]).optional(),
178
+ headless: z.boolean().optional(),
179
+ timeout: z.number().optional()
180
+ }).optional()
181
+ });
182
+ var PluginManager = class {
183
+ plugins = [];
184
+ config = null;
185
+ initialized = false;
186
+ /**
187
+ * Shared context passed to all plugins
188
+ */
189
+ sharedPayloads = [];
190
+ sharedFindings = [];
191
+ /**
192
+ * Load configuration from vulcn.config.yml
193
+ */
194
+ async loadConfig(configPath) {
195
+ const paths = configPath ? [configPath] : [
196
+ "vulcn.config.yml",
197
+ "vulcn.config.yaml",
198
+ "vulcn.config.json",
199
+ ".vulcnrc.yml",
200
+ ".vulcnrc.yaml",
201
+ ".vulcnrc.json"
202
+ ];
203
+ for (const path of paths) {
204
+ const resolved = isAbsolute2(path) ? path : resolve2(process.cwd(), path);
205
+ if (existsSync(resolved)) {
206
+ const content = await readFile(resolved, "utf-8");
207
+ const parsed = path.endsWith(".json") ? JSON.parse(content) : YAML.parse(content);
208
+ this.config = VulcnConfigSchema.parse(parsed);
209
+ return this.config;
210
+ }
211
+ }
212
+ this.config = { version: "1", plugins: [], settings: {} };
213
+ return this.config;
214
+ }
215
+ /**
216
+ * Load all plugins from config
217
+ */
218
+ async loadPlugins() {
219
+ if (!this.config) {
220
+ await this.loadConfig();
221
+ }
222
+ const pluginConfigs = this.config?.plugins || [];
223
+ for (const pluginConfig of pluginConfigs) {
224
+ if (pluginConfig.enabled === false) continue;
225
+ try {
226
+ const loaded = await this.loadPlugin(pluginConfig);
227
+ this.plugins.push(loaded);
228
+ } catch (err) {
229
+ console.error(
230
+ `Failed to load plugin ${pluginConfig.name}:`,
231
+ err instanceof Error ? err.message : String(err)
232
+ );
233
+ }
234
+ }
235
+ }
236
+ /**
237
+ * Load a single plugin
238
+ */
239
+ async loadPlugin(config) {
240
+ const { name, config: pluginConfig = {} } = config;
241
+ let plugin;
242
+ let source;
243
+ if (name.startsWith("./") || name.startsWith("../") || isAbsolute2(name)) {
244
+ const resolved = isAbsolute2(name) ? name : resolve2(process.cwd(), name);
245
+ const module = await import(resolved);
246
+ plugin = module.default || module;
247
+ source = "local";
248
+ } else if (name.startsWith("@vulcn/")) {
249
+ const module = await import(name);
250
+ plugin = module.default || module;
251
+ source = "npm";
252
+ } else {
253
+ const module = await import(name);
254
+ plugin = module.default || module;
255
+ source = "npm";
256
+ }
257
+ this.validatePlugin(plugin);
258
+ let resolvedConfig = pluginConfig;
259
+ if (plugin.configSchema) {
260
+ try {
261
+ resolvedConfig = plugin.configSchema.parse(pluginConfig);
262
+ } catch (err) {
263
+ throw new Error(
264
+ `Invalid config for plugin ${name}: ${err instanceof Error ? err.message : String(err)}`
265
+ );
266
+ }
267
+ }
268
+ return {
269
+ plugin,
270
+ config: resolvedConfig,
271
+ source,
272
+ enabled: true
273
+ };
274
+ }
275
+ /**
276
+ * Validate plugin structure
277
+ */
278
+ validatePlugin(plugin) {
279
+ if (!plugin || typeof plugin !== "object") {
280
+ throw new Error("Plugin must be an object");
281
+ }
282
+ const p = plugin;
283
+ if (typeof p.name !== "string" || !p.name) {
284
+ throw new Error("Plugin must have a name");
285
+ }
286
+ if (typeof p.version !== "string" || !p.version) {
287
+ throw new Error("Plugin must have a version");
288
+ }
289
+ const apiVersion = p.apiVersion || 1;
290
+ if (apiVersion > PLUGIN_API_VERSION) {
291
+ throw new Error(
292
+ `Plugin requires API version ${apiVersion}, but engine supports ${PLUGIN_API_VERSION}`
293
+ );
294
+ }
295
+ }
296
+ /**
297
+ * Add a plugin programmatically (for testing or dynamic loading)
298
+ */
299
+ addPlugin(plugin, config = {}) {
300
+ this.validatePlugin(plugin);
301
+ this.plugins.push({
302
+ plugin,
303
+ config,
304
+ source: "custom",
305
+ enabled: true
306
+ });
307
+ }
308
+ /**
309
+ * Initialize all plugins (call onInit hooks)
310
+ */
311
+ async initialize() {
312
+ if (this.initialized) return;
313
+ for (const loaded of this.plugins) {
314
+ if (loaded.plugin.payloads) {
315
+ const payloads = typeof loaded.plugin.payloads === "function" ? await loaded.plugin.payloads() : loaded.plugin.payloads;
316
+ this.sharedPayloads.push(...payloads);
317
+ }
318
+ }
319
+ await this.callHook("onInit", (hook, ctx) => hook(ctx));
320
+ this.initialized = true;
321
+ }
322
+ /**
323
+ * Destroy all plugins (call onDestroy hooks)
324
+ */
325
+ async destroy() {
326
+ await this.callHook("onDestroy", (hook, ctx) => hook(ctx));
327
+ this.plugins = [];
328
+ this.sharedPayloads = [];
329
+ this.sharedFindings = [];
330
+ this.initialized = false;
331
+ }
332
+ /**
333
+ * Get all loaded payloads
334
+ */
335
+ getPayloads() {
336
+ return this.sharedPayloads;
337
+ }
338
+ /**
339
+ * Get all collected findings
340
+ */
341
+ getFindings() {
342
+ return this.sharedFindings;
343
+ }
344
+ /**
345
+ * Add a finding (used by detectors)
346
+ */
347
+ addFinding(finding) {
348
+ this.sharedFindings.push(finding);
349
+ }
350
+ /**
351
+ * Add payloads (used by loaders)
352
+ */
353
+ addPayloads(payloads) {
354
+ this.sharedPayloads.push(...payloads);
355
+ }
356
+ /**
357
+ * Clear findings (for new run)
358
+ */
359
+ clearFindings() {
360
+ this.sharedFindings = [];
361
+ }
362
+ /**
363
+ * Get loaded plugins
364
+ */
365
+ getPlugins() {
366
+ return this.plugins;
367
+ }
368
+ /**
369
+ * Check if a plugin is loaded by name
370
+ */
371
+ hasPlugin(name) {
372
+ return this.plugins.some((p) => p.plugin.name === name);
373
+ }
374
+ /**
375
+ * Create base context for plugins
376
+ */
377
+ createContext(pluginConfig) {
378
+ const engineInfo = {
379
+ version: ENGINE_VERSION,
380
+ pluginApiVersion: PLUGIN_API_VERSION
381
+ };
382
+ return {
383
+ config: pluginConfig,
384
+ engine: engineInfo,
385
+ payloads: this.sharedPayloads,
386
+ findings: this.sharedFindings,
387
+ logger: this.createLogger("plugin"),
388
+ fetch: globalThis.fetch
389
+ };
390
+ }
391
+ /**
392
+ * Create scoped logger for a plugin
393
+ */
394
+ createLogger(name) {
395
+ const prefix = `[${name}]`;
396
+ return {
397
+ debug: (msg, ...args) => console.debug(prefix, msg, ...args),
398
+ info: (msg, ...args) => console.info(prefix, msg, ...args),
399
+ warn: (msg, ...args) => console.warn(prefix, msg, ...args),
400
+ error: (msg, ...args) => console.error(prefix, msg, ...args)
401
+ };
402
+ }
403
+ /**
404
+ * Call a hook on all plugins sequentially
405
+ */
406
+ async callHook(hookName, executor) {
407
+ for (const loaded of this.plugins) {
408
+ const hook = loaded.plugin.hooks?.[hookName];
409
+ if (hook) {
410
+ const ctx = this.createContext(loaded.config);
411
+ ctx.logger = this.createLogger(loaded.plugin.name);
412
+ try {
413
+ await executor(hook, ctx);
414
+ } catch (err) {
415
+ console.error(
416
+ `Error in plugin ${loaded.plugin.name}.${hookName}:`,
417
+ err instanceof Error ? err.message : String(err)
418
+ );
419
+ }
420
+ }
421
+ }
422
+ }
423
+ /**
424
+ * Call a hook and collect results
425
+ */
426
+ async callHookCollect(hookName, executor) {
427
+ const results = [];
428
+ for (const loaded of this.plugins) {
429
+ const hook = loaded.plugin.hooks?.[hookName];
430
+ if (hook) {
431
+ const ctx = this.createContext(loaded.config);
432
+ ctx.logger = this.createLogger(loaded.plugin.name);
433
+ try {
434
+ const result = await executor(
435
+ hook,
436
+ ctx
437
+ );
438
+ if (result !== null && result !== void 0) {
439
+ if (Array.isArray(result)) {
440
+ results.push(...result);
441
+ } else {
442
+ results.push(result);
443
+ }
444
+ }
445
+ } catch (err) {
446
+ console.error(
447
+ `Error in plugin ${loaded.plugin.name}.${hookName}:`,
448
+ err instanceof Error ? err.message : String(err)
449
+ );
450
+ }
451
+ }
452
+ }
453
+ return results;
454
+ }
455
+ /**
456
+ * Call a hook that transforms a value through the pipeline
457
+ */
458
+ async callHookPipe(hookName, initial, executor) {
459
+ let value = initial;
460
+ for (const loaded of this.plugins) {
461
+ const hook = loaded.plugin.hooks?.[hookName];
462
+ if (hook) {
463
+ const ctx = this.createContext(loaded.config);
464
+ ctx.logger = this.createLogger(loaded.plugin.name);
465
+ try {
466
+ value = await executor(
467
+ hook,
468
+ value,
469
+ ctx
470
+ );
471
+ } catch (err) {
472
+ console.error(
473
+ `Error in plugin ${loaded.plugin.name}.${hookName}:`,
474
+ err instanceof Error ? err.message : String(err)
475
+ );
476
+ }
477
+ }
478
+ }
479
+ return value;
480
+ }
481
+ };
482
+ var pluginManager = new PluginManager();
483
+
484
+ // src/session.ts
485
+ import { z as z2 } from "zod";
3
486
  import { parse, stringify } from "yaml";
4
- var StepSchema = z.discriminatedUnion("type", [
5
- z.object({
6
- id: z.string(),
7
- type: z.literal("navigate"),
8
- url: z.string(),
9
- timestamp: z.number()
487
+ var StepSchema = z2.discriminatedUnion("type", [
488
+ z2.object({
489
+ id: z2.string(),
490
+ type: z2.literal("navigate"),
491
+ url: z2.string(),
492
+ timestamp: z2.number()
10
493
  }),
11
- z.object({
12
- id: z.string(),
13
- type: z.literal("click"),
14
- selector: z.string(),
15
- position: z.object({ x: z.number(), y: z.number() }).optional(),
16
- timestamp: z.number()
494
+ z2.object({
495
+ id: z2.string(),
496
+ type: z2.literal("click"),
497
+ selector: z2.string(),
498
+ position: z2.object({ x: z2.number(), y: z2.number() }).optional(),
499
+ timestamp: z2.number()
17
500
  }),
18
- z.object({
19
- id: z.string(),
20
- type: z.literal("input"),
21
- selector: z.string(),
22
- value: z.string(),
23
- injectable: z.boolean().optional().default(true),
24
- timestamp: z.number()
501
+ z2.object({
502
+ id: z2.string(),
503
+ type: z2.literal("input"),
504
+ selector: z2.string(),
505
+ value: z2.string(),
506
+ injectable: z2.boolean().optional().default(true),
507
+ timestamp: z2.number()
25
508
  }),
26
- z.object({
27
- id: z.string(),
28
- type: z.literal("keypress"),
29
- key: z.string(),
30
- modifiers: z.array(z.string()).optional(),
31
- timestamp: z.number()
509
+ z2.object({
510
+ id: z2.string(),
511
+ type: z2.literal("keypress"),
512
+ key: z2.string(),
513
+ modifiers: z2.array(z2.string()).optional(),
514
+ timestamp: z2.number()
32
515
  }),
33
- z.object({
34
- id: z.string(),
35
- type: z.literal("scroll"),
36
- selector: z.string().optional(),
37
- position: z.object({ x: z.number(), y: z.number() }),
38
- timestamp: z.number()
516
+ z2.object({
517
+ id: z2.string(),
518
+ type: z2.literal("scroll"),
519
+ selector: z2.string().optional(),
520
+ position: z2.object({ x: z2.number(), y: z2.number() }),
521
+ timestamp: z2.number()
39
522
  }),
40
- z.object({
41
- id: z.string(),
42
- type: z.literal("wait"),
43
- duration: z.number(),
44
- timestamp: z.number()
523
+ z2.object({
524
+ id: z2.string(),
525
+ type: z2.literal("wait"),
526
+ duration: z2.number(),
527
+ timestamp: z2.number()
45
528
  })
46
529
  ]);
47
- var SessionSchema = z.object({
48
- version: z.string().default("1"),
49
- name: z.string(),
50
- recordedAt: z.string(),
51
- browser: z.enum(["chromium", "firefox", "webkit"]).default("chromium"),
52
- viewport: z.object({
53
- width: z.number(),
54
- height: z.number()
530
+ var SessionSchema = z2.object({
531
+ version: z2.string().default("1"),
532
+ name: z2.string(),
533
+ recordedAt: z2.string(),
534
+ browser: z2.enum(["chromium", "firefox", "webkit"]).default("chromium"),
535
+ viewport: z2.object({
536
+ width: z2.number(),
537
+ height: z2.number()
55
538
  }),
56
- startUrl: z.string(),
57
- steps: z.array(StepSchema)
539
+ startUrl: z2.string(),
540
+ steps: z2.array(StepSchema)
58
541
  });
59
542
  function createSession(options) {
60
543
  return {
@@ -75,7 +558,7 @@ function serializeSession(session) {
75
558
  return stringify(session, { lineWidth: 0 });
76
559
  }
77
560
 
78
- // browser.ts
561
+ // src/browser.ts
79
562
  import { chromium, firefox, webkit } from "playwright";
80
563
  import { exec } from "child_process";
81
564
  import { promisify } from "util";
@@ -188,16 +671,18 @@ async function checkBrowsers() {
188
671
  return results;
189
672
  }
190
673
 
191
- // recorder.ts
674
+ // src/recorder.ts
192
675
  var Recorder = class _Recorder {
193
676
  /**
194
677
  * Start a new recording session
195
678
  * Opens a browser window for the user to interact with
196
679
  */
197
- static async start(startUrl, options = {}) {
680
+ static async start(startUrl, options = {}, config = {}) {
681
+ const manager = config.pluginManager ?? pluginManager;
198
682
  const browserType = options.browser ?? "chromium";
199
683
  const viewport = options.viewport ?? { width: 1280, height: 720 };
200
684
  const headless = options.headless ?? false;
685
+ await manager.initialize();
201
686
  const { browser } = await launchBrowser({
202
687
  browser: browserType,
203
688
  headless
@@ -218,18 +703,61 @@ var Recorder = class _Recorder {
218
703
  stepCounter++;
219
704
  return `step_${String(stepCounter).padStart(3, "0")}`;
220
705
  };
221
- steps.push({
706
+ const baseRecordContext = {
707
+ startUrl,
708
+ browser: browserType,
709
+ page,
710
+ engine: { version: "0.2.0", pluginApiVersion: 1 },
711
+ payloads: manager.getPayloads(),
712
+ findings: manager.getFindings(),
713
+ logger: {
714
+ debug: console.debug.bind(console),
715
+ info: console.info.bind(console),
716
+ warn: console.warn.bind(console),
717
+ error: console.error.bind(console)
718
+ },
719
+ fetch: globalThis.fetch
720
+ };
721
+ await manager.callHook("onRecordStart", async (hook, ctx) => {
722
+ const recordCtx = { ...baseRecordContext, ...ctx };
723
+ await hook(recordCtx);
724
+ });
725
+ const initialStep = {
222
726
  id: generateStepId(),
223
727
  type: "navigate",
224
728
  url: startUrl,
225
729
  timestamp: 0
226
- });
227
- _Recorder.attachListeners(page, steps, startTime, generateStepId);
730
+ };
731
+ const transformedInitialStep = await _Recorder.transformStep(
732
+ initialStep,
733
+ manager,
734
+ baseRecordContext
735
+ );
736
+ if (transformedInitialStep) {
737
+ steps.push(transformedInitialStep);
738
+ }
739
+ _Recorder.attachListeners(
740
+ page,
741
+ steps,
742
+ startTime,
743
+ generateStepId,
744
+ manager,
745
+ baseRecordContext
746
+ );
228
747
  return {
229
748
  async stop() {
230
749
  session.steps = steps;
750
+ let finalSession = session;
751
+ for (const loaded of manager.getPlugins()) {
752
+ const hook = loaded.plugin.hooks?.onRecordEnd;
753
+ if (hook) {
754
+ const ctx = manager.createContext(loaded.config);
755
+ const recordCtx = { ...baseRecordContext, ...ctx };
756
+ finalSession = await hook(finalSession, recordCtx);
757
+ }
758
+ }
231
759
  await browser.close();
232
- return session;
760
+ return finalSession;
233
761
  },
234
762
  getSteps() {
235
763
  return [...steps];
@@ -239,8 +767,34 @@ var Recorder = class _Recorder {
239
767
  }
240
768
  };
241
769
  }
242
- static attachListeners(page, steps, startTime, generateStepId) {
770
+ /**
771
+ * Transform a step through plugin hooks
772
+ * Returns null if the step should be filtered out
773
+ */
774
+ static async transformStep(step, manager, baseContext) {
775
+ let transformedStep = step;
776
+ for (const loaded of manager.getPlugins()) {
777
+ const hook = loaded.plugin.hooks?.onRecordStep;
778
+ if (hook) {
779
+ const ctx = manager.createContext(loaded.config);
780
+ const recordCtx = { ...baseContext, ...ctx };
781
+ transformedStep = await hook(transformedStep, recordCtx);
782
+ }
783
+ }
784
+ return transformedStep;
785
+ }
786
+ static attachListeners(page, steps, startTime, generateStepId, manager, baseContext) {
243
787
  const getTimestamp = () => Date.now() - startTime;
788
+ const addStep = async (step) => {
789
+ const transformed = await _Recorder.transformStep(
790
+ step,
791
+ manager,
792
+ baseContext
793
+ );
794
+ if (transformed) {
795
+ steps.push(transformed);
796
+ }
797
+ };
244
798
  page.on("framenavigated", (frame) => {
245
799
  if (frame === page.mainFrame()) {
246
800
  const url = frame.url();
@@ -248,7 +802,7 @@ var Recorder = class _Recorder {
248
802
  if (steps.length > 0 && lastStep.type === "navigate" && lastStep.url === url) {
249
803
  return;
250
804
  }
251
- steps.push({
805
+ addStep({
252
806
  id: generateStepId(),
253
807
  type: "navigate",
254
808
  url,
@@ -258,12 +812,12 @@ var Recorder = class _Recorder {
258
812
  });
259
813
  page.exposeFunction(
260
814
  "__vulcn_record",
261
- (event) => {
815
+ async (event) => {
262
816
  const timestamp = getTimestamp();
263
817
  switch (event.type) {
264
818
  case "click": {
265
819
  const data = event.data;
266
- steps.push({
820
+ await addStep({
267
821
  id: generateStepId(),
268
822
  type: "click",
269
823
  selector: data.selector,
@@ -274,7 +828,7 @@ var Recorder = class _Recorder {
274
828
  }
275
829
  case "input": {
276
830
  const data = event.data;
277
- steps.push({
831
+ await addStep({
278
832
  id: generateStepId(),
279
833
  type: "input",
280
834
  selector: data.selector,
@@ -286,7 +840,7 @@ var Recorder = class _Recorder {
286
840
  }
287
841
  case "keypress": {
288
842
  const data = event.data;
289
- steps.push({
843
+ await addStep({
290
844
  id: generateStepId(),
291
845
  type: "keypress",
292
846
  key: data.key,
@@ -423,163 +977,156 @@ var Recorder = class _Recorder {
423
977
  }
424
978
  };
425
979
 
426
- // payloads.ts
427
- var BUILTIN_PAYLOADS = {
428
- "xss-basic": {
429
- name: "xss-basic",
430
- category: "xss",
431
- description: "Basic XSS payloads with script tags and event handlers",
432
- payloads: [
433
- '<script>alert("XSS")</script>',
434
- '<img src=x onerror=alert("XSS")>',
435
- '"><script>alert("XSS")</script>',
436
- "javascript:alert('XSS')",
437
- '<svg onload=alert("XSS")>'
438
- ],
439
- detectPatterns: [
440
- /<script[^>]*>alert\(/i,
441
- /onerror\s*=\s*alert\(/i,
442
- /onload\s*=\s*alert\(/i,
443
- /javascript:alert\(/i
444
- ]
445
- },
446
- "xss-event": {
447
- name: "xss-event",
448
- category: "xss",
449
- description: "XSS via event handlers",
450
- payloads: [
451
- '" onfocus="alert(1)" autofocus="',
452
- "' onmouseover='alert(1)'",
453
- '<body onload=alert("XSS")>',
454
- "<input onfocus=alert(1) autofocus>",
455
- "<marquee onstart=alert(1)>"
456
- ],
457
- detectPatterns: [
458
- /onfocus\s*=\s*["']?alert/i,
459
- /onmouseover\s*=\s*["']?alert/i,
460
- /onload\s*=\s*["']?alert/i,
461
- /onstart\s*=\s*["']?alert/i
462
- ]
463
- },
464
- "xss-svg": {
465
- name: "xss-svg",
466
- category: "xss",
467
- description: "XSS via SVG elements",
468
- payloads: [
469
- '<svg/onload=alert("XSS")>',
470
- "<svg><script>alert(1)</script></svg>",
471
- "<svg><animate onbegin=alert(1)>",
472
- "<svg><set onbegin=alert(1)>"
473
- ],
474
- detectPatterns: [
475
- /<svg[^>]*onload\s*=/i,
476
- /<svg[^>]*>.*<script>/i,
477
- /onbegin\s*=\s*alert/i
478
- ]
479
- },
480
- "sqli-basic": {
481
- name: "sqli-basic",
482
- category: "sqli",
483
- description: "Basic SQL injection payloads",
484
- payloads: [
485
- "' OR '1'='1",
486
- "' OR '1'='1' --",
487
- "1' OR '1'='1",
488
- "admin'--",
489
- "' UNION SELECT NULL--"
490
- ],
491
- detectPatterns: [
492
- /sql.*syntax/i,
493
- /mysql.*error/i,
494
- /ORA-\d{5}/i,
495
- /pg_query/i,
496
- /sqlite.*error/i,
497
- /unclosed.*quotation/i
498
- ]
499
- },
500
- "sqli-error": {
501
- name: "sqli-error",
502
- category: "sqli",
503
- description: "SQL injection payloads to trigger errors",
504
- payloads: ["'", "''", "`", '"', "')", `'"`, "1' AND '1'='2", "1 AND 1=2"],
505
- detectPatterns: [
506
- /sql.*syntax/i,
507
- /mysql.*error/i,
508
- /ORA-\d{5}/i,
509
- /postgresql.*error/i,
510
- /sqlite.*error/i,
511
- /quoted.*string.*properly.*terminated/i
512
- ]
513
- },
514
- "sqli-blind": {
515
- name: "sqli-blind",
516
- category: "sqli",
517
- description: "Blind SQL injection payloads",
518
- payloads: [
519
- "1' AND SLEEP(5)--",
520
- "1; WAITFOR DELAY '0:0:5'--",
521
- "1' AND (SELECT COUNT(*) FROM information_schema.tables)>0--"
522
- ],
523
- detectPatterns: [
524
- // Blind SQLi is detected by timing, not content
525
- ]
526
- }
527
- };
528
- function getPayload(name) {
529
- return BUILTIN_PAYLOADS[name];
530
- }
531
- function getPayloadNames() {
532
- return Object.keys(BUILTIN_PAYLOADS);
533
- }
534
- function getPayloadsByCategory(category) {
535
- return Object.values(BUILTIN_PAYLOADS).filter((p) => p.category === category);
536
- }
537
-
538
- // runner.ts
980
+ // src/runner.ts
539
981
  var Runner = class _Runner {
540
982
  /**
541
- * Execute a session with security payloads
983
+ * Execute a session with security payloads from plugins
984
+ *
985
+ * @param session - The recorded session to replay
986
+ * @param options - Runner configuration
987
+ * @param config - Plugin manager configuration
542
988
  */
543
- static async execute(session, payloadNames, options = {}) {
989
+ static async execute(session, options = {}, config = {}) {
990
+ const manager = config.pluginManager ?? pluginManager;
544
991
  const browserType = options.browser ?? session.browser ?? "chromium";
545
992
  const headless = options.headless ?? true;
546
993
  const startTime = Date.now();
547
- const findings = [];
548
994
  const errors = [];
549
995
  let payloadsTested = 0;
996
+ await manager.initialize();
997
+ manager.clearFindings();
998
+ const payloads = manager.getPayloads();
999
+ if (payloads.length === 0) {
1000
+ return {
1001
+ findings: [],
1002
+ stepsExecuted: session.steps.length,
1003
+ payloadsTested: 0,
1004
+ duration: Date.now() - startTime,
1005
+ errors: [
1006
+ "No payloads loaded. Add a payload plugin or configure payloads."
1007
+ ]
1008
+ };
1009
+ }
550
1010
  const { browser } = await launchBrowser({
551
1011
  browser: browserType,
552
1012
  headless
553
1013
  });
554
1014
  const context = await browser.newContext({ viewport: session.viewport });
555
1015
  const page = await context.newPage();
1016
+ const baseRunContext = {
1017
+ session,
1018
+ page,
1019
+ browser: browserType,
1020
+ headless,
1021
+ engine: { version: "0.2.0", pluginApiVersion: 1 },
1022
+ payloads: manager.getPayloads(),
1023
+ findings: manager.getFindings(),
1024
+ logger: {
1025
+ debug: console.debug.bind(console),
1026
+ info: console.info.bind(console),
1027
+ warn: console.warn.bind(console),
1028
+ error: console.error.bind(console)
1029
+ },
1030
+ fetch: globalThis.fetch
1031
+ };
1032
+ await manager.callHook("onRunStart", async (hook, ctx) => {
1033
+ const runCtx = { ...baseRunContext, ...ctx };
1034
+ await hook(runCtx);
1035
+ });
1036
+ const eventFindings = [];
1037
+ let currentDetectContext = null;
1038
+ const dialogHandler = async (dialog) => {
1039
+ if (currentDetectContext) {
1040
+ const findings = await manager.callHookCollect(
1041
+ "onDialog",
1042
+ async (hook, ctx) => {
1043
+ const detectCtx = {
1044
+ ...currentDetectContext,
1045
+ ...ctx
1046
+ };
1047
+ return hook(dialog, detectCtx);
1048
+ }
1049
+ );
1050
+ eventFindings.push(...findings);
1051
+ }
1052
+ try {
1053
+ await dialog.dismiss();
1054
+ } catch {
1055
+ }
1056
+ };
1057
+ const consoleHandler = async (msg) => {
1058
+ if (currentDetectContext) {
1059
+ const findings = await manager.callHookCollect("onConsoleMessage", async (hook, ctx) => {
1060
+ const detectCtx = { ...currentDetectContext, ...ctx };
1061
+ return hook(msg, detectCtx);
1062
+ });
1063
+ eventFindings.push(...findings);
1064
+ }
1065
+ };
1066
+ page.on("dialog", dialogHandler);
1067
+ page.on("console", consoleHandler);
556
1068
  try {
557
1069
  const injectableSteps = session.steps.filter(
558
1070
  (step) => step.type === "input" && step.injectable !== false
559
1071
  );
560
1072
  const allPayloads = [];
561
- for (const name of payloadNames) {
562
- const payload = BUILTIN_PAYLOADS[name];
563
- if (payload) {
564
- for (const value of payload.payloads) {
565
- allPayloads.push({ name, value });
566
- }
1073
+ for (const payloadSet of payloads) {
1074
+ for (const value of payloadSet.payloads) {
1075
+ allPayloads.push({ payloadSet, value });
567
1076
  }
568
1077
  }
569
1078
  for (const injectableStep of injectableSteps) {
570
- for (const payload of allPayloads) {
1079
+ for (const { payloadSet, value: originalValue } of allPayloads) {
571
1080
  try {
572
- const finding = await _Runner.replayWithPayload(
1081
+ let transformedPayload = originalValue;
1082
+ for (const loaded of manager.getPlugins()) {
1083
+ const hook = loaded.plugin.hooks?.onBeforePayload;
1084
+ if (hook) {
1085
+ const ctx = manager.createContext(loaded.config);
1086
+ const runCtx = { ...baseRunContext, ...ctx };
1087
+ transformedPayload = await hook(
1088
+ transformedPayload,
1089
+ injectableStep,
1090
+ runCtx
1091
+ );
1092
+ }
1093
+ }
1094
+ currentDetectContext = {
1095
+ ...baseRunContext,
1096
+ config: {},
1097
+ step: injectableStep,
1098
+ payloadSet,
1099
+ payloadValue: transformedPayload,
1100
+ stepId: injectableStep.id
1101
+ };
1102
+ await _Runner.replayWithPayload(
573
1103
  page,
574
1104
  session,
575
1105
  injectableStep,
576
- payload.name,
577
- payload.value
1106
+ transformedPayload
578
1107
  );
579
- if (finding) {
580
- findings.push(finding);
1108
+ const afterFindings = await manager.callHookCollect("onAfterPayload", async (hook, ctx) => {
1109
+ const detectCtx = {
1110
+ ...currentDetectContext,
1111
+ ...ctx
1112
+ };
1113
+ return hook(detectCtx);
1114
+ });
1115
+ const reflectionFinding = await _Runner.checkReflection(
1116
+ page,
1117
+ injectableStep,
1118
+ payloadSet,
1119
+ transformedPayload
1120
+ );
1121
+ const allFindings = [...afterFindings, ...eventFindings];
1122
+ if (reflectionFinding) {
1123
+ allFindings.push(reflectionFinding);
1124
+ }
1125
+ for (const finding of allFindings) {
1126
+ manager.addFinding(finding);
581
1127
  options.onFinding?.(finding);
582
1128
  }
1129
+ eventFindings.length = 0;
583
1130
  payloadsTested++;
584
1131
  } catch (err) {
585
1132
  errors.push(`${injectableStep.id}: ${String(err)}`);
@@ -587,73 +1134,94 @@ var Runner = class _Runner {
587
1134
  }
588
1135
  }
589
1136
  } finally {
1137
+ page.off("dialog", dialogHandler);
1138
+ page.off("console", consoleHandler);
1139
+ currentDetectContext = null;
590
1140
  await browser.close();
591
1141
  }
592
- return {
593
- findings,
1142
+ let result = {
1143
+ findings: manager.getFindings(),
594
1144
  stepsExecuted: session.steps.length,
595
1145
  payloadsTested,
596
1146
  duration: Date.now() - startTime,
597
1147
  errors
598
1148
  };
1149
+ for (const loaded of manager.getPlugins()) {
1150
+ const hook = loaded.plugin.hooks?.onRunEnd;
1151
+ if (hook) {
1152
+ const ctx = manager.createContext(loaded.config);
1153
+ const runCtx = { ...baseRunContext, ...ctx };
1154
+ result = await hook(result, runCtx);
1155
+ }
1156
+ }
1157
+ return result;
599
1158
  }
600
- static async replayWithPayload(page, session, targetStep, payloadName, payloadValue) {
601
- await page.goto(session.startUrl);
1159
+ /**
1160
+ * Execute with explicit payloads (legacy API, for backwards compatibility)
1161
+ */
1162
+ static async executeWithPayloads(session, payloads, options = {}) {
1163
+ const manager = new PluginManager();
1164
+ manager.addPayloads(payloads);
1165
+ return _Runner.execute(session, options, { pluginManager: manager });
1166
+ }
1167
+ /**
1168
+ * Replay session steps with payload injected at target step
1169
+ */
1170
+ static async replayWithPayload(page, session, targetStep, payloadValue) {
1171
+ await page.goto(session.startUrl, { waitUntil: "domcontentloaded" });
602
1172
  for (const step of session.steps) {
603
- if (step.type === "navigate") {
604
- await page.goto(step.url);
605
- } else if (step.type === "click") {
606
- await page.click(step.selector, { timeout: 5e3 });
607
- } else if (step.type === "input") {
608
- const value = step.id === targetStep.id ? payloadValue : step.value;
609
- await page.fill(step.selector, value, { timeout: 5e3 });
610
- } else if (step.type === "keypress") {
611
- const modifiers = step.modifiers ?? [];
612
- for (const mod of modifiers) {
613
- await page.keyboard.down(mod);
614
- }
615
- await page.keyboard.press(step.key);
616
- for (const mod of modifiers.reverse()) {
617
- await page.keyboard.up(mod);
1173
+ try {
1174
+ if (step.type === "navigate") {
1175
+ await page.goto(step.url, { waitUntil: "domcontentloaded" });
1176
+ } else if (step.type === "click") {
1177
+ await page.click(step.selector, { timeout: 5e3 });
1178
+ } else if (step.type === "input") {
1179
+ const value = step.id === targetStep.id ? payloadValue : step.value;
1180
+ await page.fill(step.selector, value, { timeout: 5e3 });
1181
+ } else if (step.type === "keypress") {
1182
+ const modifiers = step.modifiers ?? [];
1183
+ for (const mod of modifiers) {
1184
+ await page.keyboard.down(
1185
+ mod
1186
+ );
1187
+ }
1188
+ await page.keyboard.press(step.key);
1189
+ for (const mod of modifiers.reverse()) {
1190
+ await page.keyboard.up(mod);
1191
+ }
618
1192
  }
1193
+ } catch {
619
1194
  }
620
1195
  if (step.id === targetStep.id) {
621
- const finding = await _Runner.checkForVulnerability(
622
- page,
623
- targetStep,
624
- payloadName,
625
- payloadValue
626
- );
627
- if (finding) {
628
- return finding;
629
- }
1196
+ await page.waitForTimeout(100);
1197
+ break;
630
1198
  }
631
1199
  }
632
- return void 0;
633
1200
  }
634
- static async checkForVulnerability(page, step, payloadName, payloadValue) {
635
- const payload = BUILTIN_PAYLOADS[payloadName];
636
- if (!payload) return void 0;
1201
+ /**
1202
+ * Basic reflection check - fallback when no detection plugin is loaded
1203
+ */
1204
+ static async checkReflection(page, step, payloadSet, payloadValue) {
637
1205
  const content = await page.content();
638
- for (const pattern of payload.detectPatterns) {
1206
+ for (const pattern of payloadSet.detectPatterns) {
639
1207
  if (pattern.test(content)) {
640
1208
  return {
641
- type: payload.category,
642
- severity: payload.category === "xss" ? "high" : "critical",
643
- title: `${payload.category.toUpperCase()} vulnerability detected`,
644
- description: `Payload was reflected in page content`,
1209
+ type: payloadSet.category,
1210
+ severity: _Runner.getSeverity(payloadSet.category),
1211
+ title: `${payloadSet.category.toUpperCase()} vulnerability detected`,
1212
+ description: `Payload pattern was reflected in page content`,
645
1213
  stepId: step.id,
646
1214
  payload: payloadValue,
647
1215
  url: page.url(),
648
- evidence: content.match(pattern)?.[0]
1216
+ evidence: content.match(pattern)?.[0]?.slice(0, 200)
649
1217
  };
650
1218
  }
651
1219
  }
652
1220
  if (content.includes(payloadValue)) {
653
1221
  return {
654
- type: payload.category,
1222
+ type: payloadSet.category,
655
1223
  severity: "medium",
656
- title: `Potential ${payload.category.toUpperCase()} - payload reflection`,
1224
+ title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
657
1225
  description: `Payload was reflected in page without encoding`,
658
1226
  stepId: step.id,
659
1227
  payload: payloadValue,
@@ -662,22 +1230,43 @@ var Runner = class _Runner {
662
1230
  }
663
1231
  return void 0;
664
1232
  }
1233
+ /**
1234
+ * Determine severity based on vulnerability category
1235
+ */
1236
+ static getSeverity(category) {
1237
+ switch (category) {
1238
+ case "sqli":
1239
+ case "command-injection":
1240
+ case "xxe":
1241
+ return "critical";
1242
+ case "xss":
1243
+ case "ssrf":
1244
+ case "path-traversal":
1245
+ return "high";
1246
+ case "open-redirect":
1247
+ return "medium";
1248
+ default:
1249
+ return "medium";
1250
+ }
1251
+ }
665
1252
  };
666
1253
  export {
667
- BUILTIN_PAYLOADS,
668
1254
  BrowserNotFoundError,
1255
+ DRIVER_API_VERSION,
1256
+ DriverManager,
1257
+ PLUGIN_API_VERSION,
1258
+ PluginManager,
669
1259
  Recorder,
670
1260
  Runner,
671
1261
  SessionSchema,
672
1262
  StepSchema,
673
1263
  checkBrowsers,
674
1264
  createSession,
675
- getPayload,
676
- getPayloadNames,
677
- getPayloadsByCategory,
1265
+ driverManager,
678
1266
  installBrowsers,
679
1267
  launchBrowser,
680
1268
  parseSession,
1269
+ pluginManager,
681
1270
  serializeSession
682
1271
  };
683
1272
  //# sourceMappingURL=index.js.map