@vulcn/engine 0.1.0 → 0.2.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/CHANGELOG.md +1 -1
- package/LICENSE +662 -21
- package/README.md +1 -1
- package/dist/index.cjs +627 -186
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +392 -30
- package/dist/index.d.ts +392 -30
- package/dist/index.js +614 -182
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -188,16 +188,347 @@ async function checkBrowsers() {
|
|
|
188
188
|
return results;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// plugin-manager.ts
|
|
192
|
+
import { readFile } from "fs/promises";
|
|
193
|
+
import { existsSync } from "fs";
|
|
194
|
+
import { resolve, isAbsolute } from "path";
|
|
195
|
+
import YAML from "yaml";
|
|
196
|
+
import { z as z2 } from "zod";
|
|
197
|
+
|
|
198
|
+
// plugin-types.ts
|
|
199
|
+
var PLUGIN_API_VERSION = 1;
|
|
200
|
+
|
|
201
|
+
// plugin-manager.ts
|
|
202
|
+
var ENGINE_VERSION = "0.2.0";
|
|
203
|
+
var VulcnConfigSchema = z2.object({
|
|
204
|
+
version: z2.string().default("1"),
|
|
205
|
+
plugins: z2.array(
|
|
206
|
+
z2.object({
|
|
207
|
+
name: z2.string(),
|
|
208
|
+
config: z2.record(z2.unknown()).optional(),
|
|
209
|
+
enabled: z2.boolean().default(true)
|
|
210
|
+
})
|
|
211
|
+
).optional(),
|
|
212
|
+
settings: z2.object({
|
|
213
|
+
browser: z2.enum(["chromium", "firefox", "webkit"]).optional(),
|
|
214
|
+
headless: z2.boolean().optional(),
|
|
215
|
+
timeout: z2.number().optional()
|
|
216
|
+
}).optional()
|
|
217
|
+
});
|
|
218
|
+
var PluginManager = class {
|
|
219
|
+
plugins = [];
|
|
220
|
+
config = null;
|
|
221
|
+
initialized = false;
|
|
222
|
+
/**
|
|
223
|
+
* Shared context passed to all plugins
|
|
224
|
+
*/
|
|
225
|
+
sharedPayloads = [];
|
|
226
|
+
sharedFindings = [];
|
|
227
|
+
/**
|
|
228
|
+
* Load configuration from vulcn.config.yml
|
|
229
|
+
*/
|
|
230
|
+
async loadConfig(configPath) {
|
|
231
|
+
const paths = configPath ? [configPath] : [
|
|
232
|
+
"vulcn.config.yml",
|
|
233
|
+
"vulcn.config.yaml",
|
|
234
|
+
"vulcn.config.json",
|
|
235
|
+
".vulcnrc.yml",
|
|
236
|
+
".vulcnrc.yaml",
|
|
237
|
+
".vulcnrc.json"
|
|
238
|
+
];
|
|
239
|
+
for (const path of paths) {
|
|
240
|
+
const resolved = isAbsolute(path) ? path : resolve(process.cwd(), path);
|
|
241
|
+
if (existsSync(resolved)) {
|
|
242
|
+
const content = await readFile(resolved, "utf-8");
|
|
243
|
+
const parsed = path.endsWith(".json") ? JSON.parse(content) : YAML.parse(content);
|
|
244
|
+
this.config = VulcnConfigSchema.parse(parsed);
|
|
245
|
+
return this.config;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.config = { version: "1", plugins: [], settings: {} };
|
|
249
|
+
return this.config;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Load all plugins from config
|
|
253
|
+
*/
|
|
254
|
+
async loadPlugins() {
|
|
255
|
+
if (!this.config) {
|
|
256
|
+
await this.loadConfig();
|
|
257
|
+
}
|
|
258
|
+
const pluginConfigs = this.config?.plugins || [];
|
|
259
|
+
for (const pluginConfig of pluginConfigs) {
|
|
260
|
+
if (pluginConfig.enabled === false) continue;
|
|
261
|
+
try {
|
|
262
|
+
const loaded = await this.loadPlugin(pluginConfig);
|
|
263
|
+
this.plugins.push(loaded);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(
|
|
266
|
+
`Failed to load plugin ${pluginConfig.name}:`,
|
|
267
|
+
err instanceof Error ? err.message : String(err)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Load a single plugin
|
|
274
|
+
*/
|
|
275
|
+
async loadPlugin(config) {
|
|
276
|
+
const { name, config: pluginConfig = {} } = config;
|
|
277
|
+
let plugin;
|
|
278
|
+
let source;
|
|
279
|
+
if (name.startsWith("./") || name.startsWith("../") || isAbsolute(name)) {
|
|
280
|
+
const resolved = isAbsolute(name) ? name : resolve(process.cwd(), name);
|
|
281
|
+
const module = await import(resolved);
|
|
282
|
+
plugin = module.default || module;
|
|
283
|
+
source = "local";
|
|
284
|
+
} else if (name.startsWith("@vulcn/")) {
|
|
285
|
+
const module = await import(name);
|
|
286
|
+
plugin = module.default || module;
|
|
287
|
+
source = "npm";
|
|
288
|
+
} else {
|
|
289
|
+
const module = await import(name);
|
|
290
|
+
plugin = module.default || module;
|
|
291
|
+
source = "npm";
|
|
292
|
+
}
|
|
293
|
+
this.validatePlugin(plugin);
|
|
294
|
+
let resolvedConfig = pluginConfig;
|
|
295
|
+
if (plugin.configSchema) {
|
|
296
|
+
try {
|
|
297
|
+
resolvedConfig = plugin.configSchema.parse(pluginConfig);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Invalid config for plugin ${name}: ${err instanceof Error ? err.message : String(err)}`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
plugin,
|
|
306
|
+
config: resolvedConfig,
|
|
307
|
+
source,
|
|
308
|
+
enabled: true
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Validate plugin structure
|
|
313
|
+
*/
|
|
314
|
+
validatePlugin(plugin) {
|
|
315
|
+
if (!plugin || typeof plugin !== "object") {
|
|
316
|
+
throw new Error("Plugin must be an object");
|
|
317
|
+
}
|
|
318
|
+
const p = plugin;
|
|
319
|
+
if (typeof p.name !== "string" || !p.name) {
|
|
320
|
+
throw new Error("Plugin must have a name");
|
|
321
|
+
}
|
|
322
|
+
if (typeof p.version !== "string" || !p.version) {
|
|
323
|
+
throw new Error("Plugin must have a version");
|
|
324
|
+
}
|
|
325
|
+
const apiVersion = p.apiVersion || 1;
|
|
326
|
+
if (apiVersion > PLUGIN_API_VERSION) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Plugin requires API version ${apiVersion}, but engine supports ${PLUGIN_API_VERSION}`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Add a plugin programmatically (for testing or dynamic loading)
|
|
334
|
+
*/
|
|
335
|
+
addPlugin(plugin, config = {}) {
|
|
336
|
+
this.validatePlugin(plugin);
|
|
337
|
+
this.plugins.push({
|
|
338
|
+
plugin,
|
|
339
|
+
config,
|
|
340
|
+
source: "custom",
|
|
341
|
+
enabled: true
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Initialize all plugins (call onInit hooks)
|
|
346
|
+
*/
|
|
347
|
+
async initialize() {
|
|
348
|
+
if (this.initialized) return;
|
|
349
|
+
for (const loaded of this.plugins) {
|
|
350
|
+
if (loaded.plugin.payloads) {
|
|
351
|
+
const payloads = typeof loaded.plugin.payloads === "function" ? await loaded.plugin.payloads() : loaded.plugin.payloads;
|
|
352
|
+
this.sharedPayloads.push(...payloads);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
await this.callHook("onInit", (hook, ctx) => hook(ctx));
|
|
356
|
+
this.initialized = true;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Destroy all plugins (call onDestroy hooks)
|
|
360
|
+
*/
|
|
361
|
+
async destroy() {
|
|
362
|
+
await this.callHook("onDestroy", (hook, ctx) => hook(ctx));
|
|
363
|
+
this.plugins = [];
|
|
364
|
+
this.sharedPayloads = [];
|
|
365
|
+
this.sharedFindings = [];
|
|
366
|
+
this.initialized = false;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Get all loaded payloads
|
|
370
|
+
*/
|
|
371
|
+
getPayloads() {
|
|
372
|
+
return this.sharedPayloads;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Get all collected findings
|
|
376
|
+
*/
|
|
377
|
+
getFindings() {
|
|
378
|
+
return this.sharedFindings;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Add a finding (used by detectors)
|
|
382
|
+
*/
|
|
383
|
+
addFinding(finding) {
|
|
384
|
+
this.sharedFindings.push(finding);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Add payloads (used by loaders)
|
|
388
|
+
*/
|
|
389
|
+
addPayloads(payloads) {
|
|
390
|
+
this.sharedPayloads.push(...payloads);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Clear findings (for new run)
|
|
394
|
+
*/
|
|
395
|
+
clearFindings() {
|
|
396
|
+
this.sharedFindings = [];
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Get loaded plugins
|
|
400
|
+
*/
|
|
401
|
+
getPlugins() {
|
|
402
|
+
return this.plugins;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Check if a plugin is loaded by name
|
|
406
|
+
*/
|
|
407
|
+
hasPlugin(name) {
|
|
408
|
+
return this.plugins.some((p) => p.plugin.name === name);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Create base context for plugins
|
|
412
|
+
*/
|
|
413
|
+
createContext(pluginConfig) {
|
|
414
|
+
const engineInfo = {
|
|
415
|
+
version: ENGINE_VERSION,
|
|
416
|
+
pluginApiVersion: PLUGIN_API_VERSION
|
|
417
|
+
};
|
|
418
|
+
return {
|
|
419
|
+
config: pluginConfig,
|
|
420
|
+
engine: engineInfo,
|
|
421
|
+
payloads: this.sharedPayloads,
|
|
422
|
+
findings: this.sharedFindings,
|
|
423
|
+
logger: this.createLogger("plugin"),
|
|
424
|
+
fetch: globalThis.fetch
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Create scoped logger for a plugin
|
|
429
|
+
*/
|
|
430
|
+
createLogger(name) {
|
|
431
|
+
const prefix = `[${name}]`;
|
|
432
|
+
return {
|
|
433
|
+
debug: (msg, ...args) => console.debug(prefix, msg, ...args),
|
|
434
|
+
info: (msg, ...args) => console.info(prefix, msg, ...args),
|
|
435
|
+
warn: (msg, ...args) => console.warn(prefix, msg, ...args),
|
|
436
|
+
error: (msg, ...args) => console.error(prefix, msg, ...args)
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Call a hook on all plugins sequentially
|
|
441
|
+
*/
|
|
442
|
+
async callHook(hookName, executor) {
|
|
443
|
+
for (const loaded of this.plugins) {
|
|
444
|
+
const hook = loaded.plugin.hooks?.[hookName];
|
|
445
|
+
if (hook) {
|
|
446
|
+
const ctx = this.createContext(loaded.config);
|
|
447
|
+
ctx.logger = this.createLogger(loaded.plugin.name);
|
|
448
|
+
try {
|
|
449
|
+
await executor(hook, ctx);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.error(
|
|
452
|
+
`Error in plugin ${loaded.plugin.name}.${hookName}:`,
|
|
453
|
+
err instanceof Error ? err.message : String(err)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Call a hook and collect results
|
|
461
|
+
*/
|
|
462
|
+
async callHookCollect(hookName, executor) {
|
|
463
|
+
const results = [];
|
|
464
|
+
for (const loaded of this.plugins) {
|
|
465
|
+
const hook = loaded.plugin.hooks?.[hookName];
|
|
466
|
+
if (hook) {
|
|
467
|
+
const ctx = this.createContext(loaded.config);
|
|
468
|
+
ctx.logger = this.createLogger(loaded.plugin.name);
|
|
469
|
+
try {
|
|
470
|
+
const result = await executor(
|
|
471
|
+
hook,
|
|
472
|
+
ctx
|
|
473
|
+
);
|
|
474
|
+
if (result !== null && result !== void 0) {
|
|
475
|
+
if (Array.isArray(result)) {
|
|
476
|
+
results.push(...result);
|
|
477
|
+
} else {
|
|
478
|
+
results.push(result);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} catch (err) {
|
|
482
|
+
console.error(
|
|
483
|
+
`Error in plugin ${loaded.plugin.name}.${hookName}:`,
|
|
484
|
+
err instanceof Error ? err.message : String(err)
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return results;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Call a hook that transforms a value through the pipeline
|
|
493
|
+
*/
|
|
494
|
+
async callHookPipe(hookName, initial, executor) {
|
|
495
|
+
let value = initial;
|
|
496
|
+
for (const loaded of this.plugins) {
|
|
497
|
+
const hook = loaded.plugin.hooks?.[hookName];
|
|
498
|
+
if (hook) {
|
|
499
|
+
const ctx = this.createContext(loaded.config);
|
|
500
|
+
ctx.logger = this.createLogger(loaded.plugin.name);
|
|
501
|
+
try {
|
|
502
|
+
value = await executor(
|
|
503
|
+
hook,
|
|
504
|
+
value,
|
|
505
|
+
ctx
|
|
506
|
+
);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error(
|
|
509
|
+
`Error in plugin ${loaded.plugin.name}.${hookName}:`,
|
|
510
|
+
err instanceof Error ? err.message : String(err)
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return value;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
var pluginManager = new PluginManager();
|
|
519
|
+
|
|
191
520
|
// recorder.ts
|
|
192
521
|
var Recorder = class _Recorder {
|
|
193
522
|
/**
|
|
194
523
|
* Start a new recording session
|
|
195
524
|
* Opens a browser window for the user to interact with
|
|
196
525
|
*/
|
|
197
|
-
static async start(startUrl, options = {}) {
|
|
526
|
+
static async start(startUrl, options = {}, config = {}) {
|
|
527
|
+
const manager = config.pluginManager ?? pluginManager;
|
|
198
528
|
const browserType = options.browser ?? "chromium";
|
|
199
529
|
const viewport = options.viewport ?? { width: 1280, height: 720 };
|
|
200
530
|
const headless = options.headless ?? false;
|
|
531
|
+
await manager.initialize();
|
|
201
532
|
const { browser } = await launchBrowser({
|
|
202
533
|
browser: browserType,
|
|
203
534
|
headless
|
|
@@ -218,18 +549,61 @@ var Recorder = class _Recorder {
|
|
|
218
549
|
stepCounter++;
|
|
219
550
|
return `step_${String(stepCounter).padStart(3, "0")}`;
|
|
220
551
|
};
|
|
221
|
-
|
|
552
|
+
const baseRecordContext = {
|
|
553
|
+
startUrl,
|
|
554
|
+
browser: browserType,
|
|
555
|
+
page,
|
|
556
|
+
engine: { version: "0.2.0", pluginApiVersion: 1 },
|
|
557
|
+
payloads: manager.getPayloads(),
|
|
558
|
+
findings: manager.getFindings(),
|
|
559
|
+
logger: {
|
|
560
|
+
debug: console.debug.bind(console),
|
|
561
|
+
info: console.info.bind(console),
|
|
562
|
+
warn: console.warn.bind(console),
|
|
563
|
+
error: console.error.bind(console)
|
|
564
|
+
},
|
|
565
|
+
fetch: globalThis.fetch
|
|
566
|
+
};
|
|
567
|
+
await manager.callHook("onRecordStart", async (hook, ctx) => {
|
|
568
|
+
const recordCtx = { ...baseRecordContext, ...ctx };
|
|
569
|
+
await hook(recordCtx);
|
|
570
|
+
});
|
|
571
|
+
const initialStep = {
|
|
222
572
|
id: generateStepId(),
|
|
223
573
|
type: "navigate",
|
|
224
574
|
url: startUrl,
|
|
225
575
|
timestamp: 0
|
|
226
|
-
}
|
|
227
|
-
_Recorder.
|
|
576
|
+
};
|
|
577
|
+
const transformedInitialStep = await _Recorder.transformStep(
|
|
578
|
+
initialStep,
|
|
579
|
+
manager,
|
|
580
|
+
baseRecordContext
|
|
581
|
+
);
|
|
582
|
+
if (transformedInitialStep) {
|
|
583
|
+
steps.push(transformedInitialStep);
|
|
584
|
+
}
|
|
585
|
+
_Recorder.attachListeners(
|
|
586
|
+
page,
|
|
587
|
+
steps,
|
|
588
|
+
startTime,
|
|
589
|
+
generateStepId,
|
|
590
|
+
manager,
|
|
591
|
+
baseRecordContext
|
|
592
|
+
);
|
|
228
593
|
return {
|
|
229
594
|
async stop() {
|
|
230
595
|
session.steps = steps;
|
|
596
|
+
let finalSession = session;
|
|
597
|
+
for (const loaded of manager.getPlugins()) {
|
|
598
|
+
const hook = loaded.plugin.hooks?.onRecordEnd;
|
|
599
|
+
if (hook) {
|
|
600
|
+
const ctx = manager.createContext(loaded.config);
|
|
601
|
+
const recordCtx = { ...baseRecordContext, ...ctx };
|
|
602
|
+
finalSession = await hook(finalSession, recordCtx);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
231
605
|
await browser.close();
|
|
232
|
-
return
|
|
606
|
+
return finalSession;
|
|
233
607
|
},
|
|
234
608
|
getSteps() {
|
|
235
609
|
return [...steps];
|
|
@@ -239,8 +613,34 @@ var Recorder = class _Recorder {
|
|
|
239
613
|
}
|
|
240
614
|
};
|
|
241
615
|
}
|
|
242
|
-
|
|
616
|
+
/**
|
|
617
|
+
* Transform a step through plugin hooks
|
|
618
|
+
* Returns null if the step should be filtered out
|
|
619
|
+
*/
|
|
620
|
+
static async transformStep(step, manager, baseContext) {
|
|
621
|
+
let transformedStep = step;
|
|
622
|
+
for (const loaded of manager.getPlugins()) {
|
|
623
|
+
const hook = loaded.plugin.hooks?.onRecordStep;
|
|
624
|
+
if (hook) {
|
|
625
|
+
const ctx = manager.createContext(loaded.config);
|
|
626
|
+
const recordCtx = { ...baseContext, ...ctx };
|
|
627
|
+
transformedStep = await hook(transformedStep, recordCtx);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return transformedStep;
|
|
631
|
+
}
|
|
632
|
+
static attachListeners(page, steps, startTime, generateStepId, manager, baseContext) {
|
|
243
633
|
const getTimestamp = () => Date.now() - startTime;
|
|
634
|
+
const addStep = async (step) => {
|
|
635
|
+
const transformed = await _Recorder.transformStep(
|
|
636
|
+
step,
|
|
637
|
+
manager,
|
|
638
|
+
baseContext
|
|
639
|
+
);
|
|
640
|
+
if (transformed) {
|
|
641
|
+
steps.push(transformed);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
244
644
|
page.on("framenavigated", (frame) => {
|
|
245
645
|
if (frame === page.mainFrame()) {
|
|
246
646
|
const url = frame.url();
|
|
@@ -248,7 +648,7 @@ var Recorder = class _Recorder {
|
|
|
248
648
|
if (steps.length > 0 && lastStep.type === "navigate" && lastStep.url === url) {
|
|
249
649
|
return;
|
|
250
650
|
}
|
|
251
|
-
|
|
651
|
+
addStep({
|
|
252
652
|
id: generateStepId(),
|
|
253
653
|
type: "navigate",
|
|
254
654
|
url,
|
|
@@ -258,12 +658,12 @@ var Recorder = class _Recorder {
|
|
|
258
658
|
});
|
|
259
659
|
page.exposeFunction(
|
|
260
660
|
"__vulcn_record",
|
|
261
|
-
(event) => {
|
|
661
|
+
async (event) => {
|
|
262
662
|
const timestamp = getTimestamp();
|
|
263
663
|
switch (event.type) {
|
|
264
664
|
case "click": {
|
|
265
665
|
const data = event.data;
|
|
266
|
-
|
|
666
|
+
await addStep({
|
|
267
667
|
id: generateStepId(),
|
|
268
668
|
type: "click",
|
|
269
669
|
selector: data.selector,
|
|
@@ -274,7 +674,7 @@ var Recorder = class _Recorder {
|
|
|
274
674
|
}
|
|
275
675
|
case "input": {
|
|
276
676
|
const data = event.data;
|
|
277
|
-
|
|
677
|
+
await addStep({
|
|
278
678
|
id: generateStepId(),
|
|
279
679
|
type: "input",
|
|
280
680
|
selector: data.selector,
|
|
@@ -286,7 +686,7 @@ var Recorder = class _Recorder {
|
|
|
286
686
|
}
|
|
287
687
|
case "keypress": {
|
|
288
688
|
const data = event.data;
|
|
289
|
-
|
|
689
|
+
await addStep({
|
|
290
690
|
id: generateStepId(),
|
|
291
691
|
type: "keypress",
|
|
292
692
|
key: data.key,
|
|
@@ -423,163 +823,156 @@ var Recorder = class _Recorder {
|
|
|
423
823
|
}
|
|
424
824
|
};
|
|
425
825
|
|
|
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
826
|
// runner.ts
|
|
539
827
|
var Runner = class _Runner {
|
|
540
828
|
/**
|
|
541
|
-
* Execute a session with security payloads
|
|
829
|
+
* Execute a session with security payloads from plugins
|
|
830
|
+
*
|
|
831
|
+
* @param session - The recorded session to replay
|
|
832
|
+
* @param options - Runner configuration
|
|
833
|
+
* @param config - Plugin manager configuration
|
|
542
834
|
*/
|
|
543
|
-
static async execute(session,
|
|
835
|
+
static async execute(session, options = {}, config = {}) {
|
|
836
|
+
const manager = config.pluginManager ?? pluginManager;
|
|
544
837
|
const browserType = options.browser ?? session.browser ?? "chromium";
|
|
545
838
|
const headless = options.headless ?? true;
|
|
546
839
|
const startTime = Date.now();
|
|
547
|
-
const findings = [];
|
|
548
840
|
const errors = [];
|
|
549
841
|
let payloadsTested = 0;
|
|
842
|
+
await manager.initialize();
|
|
843
|
+
manager.clearFindings();
|
|
844
|
+
const payloads = manager.getPayloads();
|
|
845
|
+
if (payloads.length === 0) {
|
|
846
|
+
return {
|
|
847
|
+
findings: [],
|
|
848
|
+
stepsExecuted: session.steps.length,
|
|
849
|
+
payloadsTested: 0,
|
|
850
|
+
duration: Date.now() - startTime,
|
|
851
|
+
errors: [
|
|
852
|
+
"No payloads loaded. Add a payload plugin or configure payloads."
|
|
853
|
+
]
|
|
854
|
+
};
|
|
855
|
+
}
|
|
550
856
|
const { browser } = await launchBrowser({
|
|
551
857
|
browser: browserType,
|
|
552
858
|
headless
|
|
553
859
|
});
|
|
554
860
|
const context = await browser.newContext({ viewport: session.viewport });
|
|
555
861
|
const page = await context.newPage();
|
|
862
|
+
const baseRunContext = {
|
|
863
|
+
session,
|
|
864
|
+
page,
|
|
865
|
+
browser: browserType,
|
|
866
|
+
headless,
|
|
867
|
+
engine: { version: "0.2.0", pluginApiVersion: 1 },
|
|
868
|
+
payloads: manager.getPayloads(),
|
|
869
|
+
findings: manager.getFindings(),
|
|
870
|
+
logger: {
|
|
871
|
+
debug: console.debug.bind(console),
|
|
872
|
+
info: console.info.bind(console),
|
|
873
|
+
warn: console.warn.bind(console),
|
|
874
|
+
error: console.error.bind(console)
|
|
875
|
+
},
|
|
876
|
+
fetch: globalThis.fetch
|
|
877
|
+
};
|
|
878
|
+
await manager.callHook("onRunStart", async (hook, ctx) => {
|
|
879
|
+
const runCtx = { ...baseRunContext, ...ctx };
|
|
880
|
+
await hook(runCtx);
|
|
881
|
+
});
|
|
882
|
+
const eventFindings = [];
|
|
883
|
+
let currentDetectContext = null;
|
|
884
|
+
const dialogHandler = async (dialog) => {
|
|
885
|
+
if (currentDetectContext) {
|
|
886
|
+
const findings = await manager.callHookCollect(
|
|
887
|
+
"onDialog",
|
|
888
|
+
async (hook, ctx) => {
|
|
889
|
+
const detectCtx = {
|
|
890
|
+
...currentDetectContext,
|
|
891
|
+
...ctx
|
|
892
|
+
};
|
|
893
|
+
return hook(dialog, detectCtx);
|
|
894
|
+
}
|
|
895
|
+
);
|
|
896
|
+
eventFindings.push(...findings);
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
await dialog.dismiss();
|
|
900
|
+
} catch {
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
const consoleHandler = async (msg) => {
|
|
904
|
+
if (currentDetectContext) {
|
|
905
|
+
const findings = await manager.callHookCollect("onConsoleMessage", async (hook, ctx) => {
|
|
906
|
+
const detectCtx = { ...currentDetectContext, ...ctx };
|
|
907
|
+
return hook(msg, detectCtx);
|
|
908
|
+
});
|
|
909
|
+
eventFindings.push(...findings);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
page.on("dialog", dialogHandler);
|
|
913
|
+
page.on("console", consoleHandler);
|
|
556
914
|
try {
|
|
557
915
|
const injectableSteps = session.steps.filter(
|
|
558
916
|
(step) => step.type === "input" && step.injectable !== false
|
|
559
917
|
);
|
|
560
918
|
const allPayloads = [];
|
|
561
|
-
for (const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
for (const value of payload.payloads) {
|
|
565
|
-
allPayloads.push({ name, value });
|
|
566
|
-
}
|
|
919
|
+
for (const payloadSet of payloads) {
|
|
920
|
+
for (const value of payloadSet.payloads) {
|
|
921
|
+
allPayloads.push({ payloadSet, value });
|
|
567
922
|
}
|
|
568
923
|
}
|
|
569
924
|
for (const injectableStep of injectableSteps) {
|
|
570
|
-
for (const
|
|
925
|
+
for (const { payloadSet, value: originalValue } of allPayloads) {
|
|
571
926
|
try {
|
|
572
|
-
|
|
927
|
+
let transformedPayload = originalValue;
|
|
928
|
+
for (const loaded of manager.getPlugins()) {
|
|
929
|
+
const hook = loaded.plugin.hooks?.onBeforePayload;
|
|
930
|
+
if (hook) {
|
|
931
|
+
const ctx = manager.createContext(loaded.config);
|
|
932
|
+
const runCtx = { ...baseRunContext, ...ctx };
|
|
933
|
+
transformedPayload = await hook(
|
|
934
|
+
transformedPayload,
|
|
935
|
+
injectableStep,
|
|
936
|
+
runCtx
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
currentDetectContext = {
|
|
941
|
+
...baseRunContext,
|
|
942
|
+
config: {},
|
|
943
|
+
step: injectableStep,
|
|
944
|
+
payloadSet,
|
|
945
|
+
payloadValue: transformedPayload,
|
|
946
|
+
stepId: injectableStep.id
|
|
947
|
+
};
|
|
948
|
+
await _Runner.replayWithPayload(
|
|
573
949
|
page,
|
|
574
950
|
session,
|
|
575
951
|
injectableStep,
|
|
576
|
-
|
|
577
|
-
payload.value
|
|
952
|
+
transformedPayload
|
|
578
953
|
);
|
|
579
|
-
|
|
580
|
-
|
|
954
|
+
const afterFindings = await manager.callHookCollect("onAfterPayload", async (hook, ctx) => {
|
|
955
|
+
const detectCtx = {
|
|
956
|
+
...currentDetectContext,
|
|
957
|
+
...ctx
|
|
958
|
+
};
|
|
959
|
+
return hook(detectCtx);
|
|
960
|
+
});
|
|
961
|
+
const reflectionFinding = await _Runner.checkReflection(
|
|
962
|
+
page,
|
|
963
|
+
injectableStep,
|
|
964
|
+
payloadSet,
|
|
965
|
+
transformedPayload
|
|
966
|
+
);
|
|
967
|
+
const allFindings = [...afterFindings, ...eventFindings];
|
|
968
|
+
if (reflectionFinding) {
|
|
969
|
+
allFindings.push(reflectionFinding);
|
|
970
|
+
}
|
|
971
|
+
for (const finding of allFindings) {
|
|
972
|
+
manager.addFinding(finding);
|
|
581
973
|
options.onFinding?.(finding);
|
|
582
974
|
}
|
|
975
|
+
eventFindings.length = 0;
|
|
583
976
|
payloadsTested++;
|
|
584
977
|
} catch (err) {
|
|
585
978
|
errors.push(`${injectableStep.id}: ${String(err)}`);
|
|
@@ -587,73 +980,94 @@ var Runner = class _Runner {
|
|
|
587
980
|
}
|
|
588
981
|
}
|
|
589
982
|
} finally {
|
|
983
|
+
page.off("dialog", dialogHandler);
|
|
984
|
+
page.off("console", consoleHandler);
|
|
985
|
+
currentDetectContext = null;
|
|
590
986
|
await browser.close();
|
|
591
987
|
}
|
|
592
|
-
|
|
593
|
-
findings,
|
|
988
|
+
let result = {
|
|
989
|
+
findings: manager.getFindings(),
|
|
594
990
|
stepsExecuted: session.steps.length,
|
|
595
991
|
payloadsTested,
|
|
596
992
|
duration: Date.now() - startTime,
|
|
597
993
|
errors
|
|
598
994
|
};
|
|
995
|
+
for (const loaded of manager.getPlugins()) {
|
|
996
|
+
const hook = loaded.plugin.hooks?.onRunEnd;
|
|
997
|
+
if (hook) {
|
|
998
|
+
const ctx = manager.createContext(loaded.config);
|
|
999
|
+
const runCtx = { ...baseRunContext, ...ctx };
|
|
1000
|
+
result = await hook(result, runCtx);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return result;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Execute with explicit payloads (legacy API, for backwards compatibility)
|
|
1007
|
+
*/
|
|
1008
|
+
static async executeWithPayloads(session, payloads, options = {}) {
|
|
1009
|
+
const manager = new PluginManager();
|
|
1010
|
+
manager.addPayloads(payloads);
|
|
1011
|
+
return _Runner.execute(session, options, { pluginManager: manager });
|
|
599
1012
|
}
|
|
600
|
-
|
|
601
|
-
|
|
1013
|
+
/**
|
|
1014
|
+
* Replay session steps with payload injected at target step
|
|
1015
|
+
*/
|
|
1016
|
+
static async replayWithPayload(page, session, targetStep, payloadValue) {
|
|
1017
|
+
await page.goto(session.startUrl, { waitUntil: "domcontentloaded" });
|
|
602
1018
|
for (const step of session.steps) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1019
|
+
try {
|
|
1020
|
+
if (step.type === "navigate") {
|
|
1021
|
+
await page.goto(step.url, { waitUntil: "domcontentloaded" });
|
|
1022
|
+
} else if (step.type === "click") {
|
|
1023
|
+
await page.click(step.selector, { timeout: 5e3 });
|
|
1024
|
+
} else if (step.type === "input") {
|
|
1025
|
+
const value = step.id === targetStep.id ? payloadValue : step.value;
|
|
1026
|
+
await page.fill(step.selector, value, { timeout: 5e3 });
|
|
1027
|
+
} else if (step.type === "keypress") {
|
|
1028
|
+
const modifiers = step.modifiers ?? [];
|
|
1029
|
+
for (const mod of modifiers) {
|
|
1030
|
+
await page.keyboard.down(
|
|
1031
|
+
mod
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
await page.keyboard.press(step.key);
|
|
1035
|
+
for (const mod of modifiers.reverse()) {
|
|
1036
|
+
await page.keyboard.up(mod);
|
|
1037
|
+
}
|
|
618
1038
|
}
|
|
1039
|
+
} catch {
|
|
619
1040
|
}
|
|
620
1041
|
if (step.id === targetStep.id) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
targetStep,
|
|
624
|
-
payloadName,
|
|
625
|
-
payloadValue
|
|
626
|
-
);
|
|
627
|
-
if (finding) {
|
|
628
|
-
return finding;
|
|
629
|
-
}
|
|
1042
|
+
await page.waitForTimeout(100);
|
|
1043
|
+
break;
|
|
630
1044
|
}
|
|
631
1045
|
}
|
|
632
|
-
return void 0;
|
|
633
1046
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1047
|
+
/**
|
|
1048
|
+
* Basic reflection check - fallback when no detection plugin is loaded
|
|
1049
|
+
*/
|
|
1050
|
+
static async checkReflection(page, step, payloadSet, payloadValue) {
|
|
637
1051
|
const content = await page.content();
|
|
638
|
-
for (const pattern of
|
|
1052
|
+
for (const pattern of payloadSet.detectPatterns) {
|
|
639
1053
|
if (pattern.test(content)) {
|
|
640
1054
|
return {
|
|
641
|
-
type:
|
|
642
|
-
severity:
|
|
643
|
-
title: `${
|
|
644
|
-
description: `Payload was reflected in page content`,
|
|
1055
|
+
type: payloadSet.category,
|
|
1056
|
+
severity: _Runner.getSeverity(payloadSet.category),
|
|
1057
|
+
title: `${payloadSet.category.toUpperCase()} vulnerability detected`,
|
|
1058
|
+
description: `Payload pattern was reflected in page content`,
|
|
645
1059
|
stepId: step.id,
|
|
646
1060
|
payload: payloadValue,
|
|
647
1061
|
url: page.url(),
|
|
648
|
-
evidence: content.match(pattern)?.[0]
|
|
1062
|
+
evidence: content.match(pattern)?.[0]?.slice(0, 200)
|
|
649
1063
|
};
|
|
650
1064
|
}
|
|
651
1065
|
}
|
|
652
1066
|
if (content.includes(payloadValue)) {
|
|
653
1067
|
return {
|
|
654
|
-
type:
|
|
1068
|
+
type: payloadSet.category,
|
|
655
1069
|
severity: "medium",
|
|
656
|
-
title: `Potential ${
|
|
1070
|
+
title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
|
|
657
1071
|
description: `Payload was reflected in page without encoding`,
|
|
658
1072
|
stepId: step.id,
|
|
659
1073
|
payload: payloadValue,
|
|
@@ -662,22 +1076,40 @@ var Runner = class _Runner {
|
|
|
662
1076
|
}
|
|
663
1077
|
return void 0;
|
|
664
1078
|
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Determine severity based on vulnerability category
|
|
1081
|
+
*/
|
|
1082
|
+
static getSeverity(category) {
|
|
1083
|
+
switch (category) {
|
|
1084
|
+
case "sqli":
|
|
1085
|
+
case "command-injection":
|
|
1086
|
+
case "xxe":
|
|
1087
|
+
return "critical";
|
|
1088
|
+
case "xss":
|
|
1089
|
+
case "ssrf":
|
|
1090
|
+
case "path-traversal":
|
|
1091
|
+
return "high";
|
|
1092
|
+
case "open-redirect":
|
|
1093
|
+
return "medium";
|
|
1094
|
+
default:
|
|
1095
|
+
return "medium";
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
665
1098
|
};
|
|
666
1099
|
export {
|
|
667
|
-
BUILTIN_PAYLOADS,
|
|
668
1100
|
BrowserNotFoundError,
|
|
1101
|
+
PLUGIN_API_VERSION,
|
|
1102
|
+
PluginManager,
|
|
669
1103
|
Recorder,
|
|
670
1104
|
Runner,
|
|
671
1105
|
SessionSchema,
|
|
672
1106
|
StepSchema,
|
|
673
1107
|
checkBrowsers,
|
|
674
1108
|
createSession,
|
|
675
|
-
getPayload,
|
|
676
|
-
getPayloadNames,
|
|
677
|
-
getPayloadsByCategory,
|
|
678
1109
|
installBrowsers,
|
|
679
1110
|
launchBrowser,
|
|
680
1111
|
parseSession,
|
|
1112
|
+
pluginManager,
|
|
681
1113
|
serializeSession
|
|
682
1114
|
};
|
|
683
1115
|
//# sourceMappingURL=index.js.map
|