@vulcn/engine 0.3.0 → 0.3.2
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 +65 -0
- package/dist/index.cjs +108 -797
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -585
- package/dist/index.d.ts +82 -585
- package/dist/index.js +105 -783
- package/dist/index.js.map +1 -1
- package/package.json +38 -33
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/driver-manager.ts
|
|
2
2
|
import { isAbsolute, resolve } from "path";
|
|
3
|
+
import { parse } from "yaml";
|
|
3
4
|
var DriverManager = class {
|
|
4
5
|
drivers = /* @__PURE__ */ new Map();
|
|
5
6
|
defaultDriver = null;
|
|
@@ -78,6 +79,48 @@ var DriverManager = class {
|
|
|
78
79
|
}
|
|
79
80
|
return driver;
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse a YAML session string into a Session object.
|
|
84
|
+
*
|
|
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").
|
|
89
|
+
*
|
|
90
|
+
* @param yaml - Raw YAML string
|
|
91
|
+
* @param defaultDriver - Driver to assign for legacy sessions (default: "browser")
|
|
92
|
+
*/
|
|
93
|
+
parseSession(yaml, defaultDriver = "browser") {
|
|
94
|
+
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
|
+
};
|
|
123
|
+
}
|
|
81
124
|
/**
|
|
82
125
|
* Start recording with a driver
|
|
83
126
|
*/
|
|
@@ -88,8 +131,31 @@ var DriverManager = class {
|
|
|
88
131
|
}
|
|
89
132
|
return driver.recorder.start(config, options);
|
|
90
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Auto-crawl a URL using a driver.
|
|
136
|
+
*
|
|
137
|
+
* Uses the driver's optional crawl() method to automatically
|
|
138
|
+
* discover forms and injection points, returning Session[] that
|
|
139
|
+
* can be passed to execute().
|
|
140
|
+
*
|
|
141
|
+
* Not all drivers support this — only browser has crawl capability.
|
|
142
|
+
* CLI and API drivers will throw.
|
|
143
|
+
*/
|
|
144
|
+
async crawl(driverName, config, options = {}) {
|
|
145
|
+
const driver = this.get(driverName);
|
|
146
|
+
if (!driver) {
|
|
147
|
+
throw new Error(`Driver "${driverName}" not found`);
|
|
148
|
+
}
|
|
149
|
+
if (!driver.recorder.crawl) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Driver "${driverName}" does not support auto-crawl. Use manual recording instead.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return driver.recorder.crawl(config, options);
|
|
155
|
+
}
|
|
91
156
|
/**
|
|
92
157
|
* Execute a session
|
|
158
|
+
* Invokes plugin hooks (onRunStart, onRunEnd) around the driver runner.
|
|
93
159
|
*/
|
|
94
160
|
async execute(session, pluginManager2, options = {}) {
|
|
95
161
|
const driver = this.getForSession(session);
|
|
@@ -108,7 +174,44 @@ var DriverManager = class {
|
|
|
108
174
|
logger,
|
|
109
175
|
options
|
|
110
176
|
};
|
|
111
|
-
|
|
177
|
+
const pluginCtx = {
|
|
178
|
+
session,
|
|
179
|
+
page: null,
|
|
180
|
+
headless: !!options.headless,
|
|
181
|
+
config: {},
|
|
182
|
+
engine: { version: "0.3.0", pluginApiVersion: 1 },
|
|
183
|
+
payloads: pluginManager2.getPayloads(),
|
|
184
|
+
findings,
|
|
185
|
+
logger,
|
|
186
|
+
fetch: globalThis.fetch
|
|
187
|
+
};
|
|
188
|
+
for (const loaded of pluginManager2.getPlugins()) {
|
|
189
|
+
if (loaded.enabled && loaded.plugin.hooks?.onRunStart) {
|
|
190
|
+
try {
|
|
191
|
+
await loaded.plugin.hooks.onRunStart({
|
|
192
|
+
...pluginCtx,
|
|
193
|
+
config: loaded.config
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
logger.warn(`Plugin ${loaded.plugin.name} onRunStart failed: ${err}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let result = await driver.runner.execute(session, ctx);
|
|
201
|
+
for (const loaded of pluginManager2.getPlugins()) {
|
|
202
|
+
if (loaded.enabled && loaded.plugin.hooks?.onRunEnd) {
|
|
203
|
+
try {
|
|
204
|
+
result = await loaded.plugin.hooks.onRunEnd(result, {
|
|
205
|
+
...pluginCtx,
|
|
206
|
+
config: loaded.config,
|
|
207
|
+
findings: result.findings
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
logger.warn(`Plugin ${loaded.plugin.name} onRunEnd failed: ${err}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
112
215
|
}
|
|
113
216
|
/**
|
|
114
217
|
* Validate driver structure
|
|
@@ -480,793 +583,12 @@ var PluginManager = class {
|
|
|
480
583
|
}
|
|
481
584
|
};
|
|
482
585
|
var pluginManager = new PluginManager();
|
|
483
|
-
|
|
484
|
-
// src/session.ts
|
|
485
|
-
import { z as z2 } from "zod";
|
|
486
|
-
import { parse, stringify } from "yaml";
|
|
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()
|
|
493
|
-
}),
|
|
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()
|
|
500
|
-
}),
|
|
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()
|
|
508
|
-
}),
|
|
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()
|
|
515
|
-
}),
|
|
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()
|
|
522
|
-
}),
|
|
523
|
-
z2.object({
|
|
524
|
-
id: z2.string(),
|
|
525
|
-
type: z2.literal("wait"),
|
|
526
|
-
duration: z2.number(),
|
|
527
|
-
timestamp: z2.number()
|
|
528
|
-
})
|
|
529
|
-
]);
|
|
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()
|
|
538
|
-
}),
|
|
539
|
-
startUrl: z2.string(),
|
|
540
|
-
steps: z2.array(StepSchema)
|
|
541
|
-
});
|
|
542
|
-
function createSession(options) {
|
|
543
|
-
return {
|
|
544
|
-
version: "1",
|
|
545
|
-
name: options.name,
|
|
546
|
-
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
547
|
-
browser: options.browser ?? "chromium",
|
|
548
|
-
viewport: options.viewport ?? { width: 1280, height: 720 },
|
|
549
|
-
startUrl: options.startUrl,
|
|
550
|
-
steps: []
|
|
551
|
-
};
|
|
552
|
-
}
|
|
553
|
-
function parseSession(yaml) {
|
|
554
|
-
const data = parse(yaml);
|
|
555
|
-
return SessionSchema.parse(data);
|
|
556
|
-
}
|
|
557
|
-
function serializeSession(session) {
|
|
558
|
-
return stringify(session, { lineWidth: 0 });
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// src/browser.ts
|
|
562
|
-
import { chromium, firefox, webkit } from "playwright";
|
|
563
|
-
import { exec } from "child_process";
|
|
564
|
-
import { promisify } from "util";
|
|
565
|
-
var execAsync = promisify(exec);
|
|
566
|
-
var BrowserNotFoundError = class extends Error {
|
|
567
|
-
constructor(message) {
|
|
568
|
-
super(message);
|
|
569
|
-
this.name = "BrowserNotFoundError";
|
|
570
|
-
}
|
|
571
|
-
};
|
|
572
|
-
async function launchBrowser(options = {}) {
|
|
573
|
-
const browserType = options.browser ?? "chromium";
|
|
574
|
-
const headless = options.headless ?? false;
|
|
575
|
-
if (browserType === "chromium") {
|
|
576
|
-
try {
|
|
577
|
-
const browser = await chromium.launch({
|
|
578
|
-
channel: "chrome",
|
|
579
|
-
headless
|
|
580
|
-
});
|
|
581
|
-
return { browser, channel: "chrome" };
|
|
582
|
-
} catch {
|
|
583
|
-
}
|
|
584
|
-
try {
|
|
585
|
-
const browser = await chromium.launch({
|
|
586
|
-
channel: "msedge",
|
|
587
|
-
headless
|
|
588
|
-
});
|
|
589
|
-
return { browser, channel: "msedge" };
|
|
590
|
-
} catch {
|
|
591
|
-
}
|
|
592
|
-
try {
|
|
593
|
-
const browser = await chromium.launch({ headless });
|
|
594
|
-
return { browser, channel: "chromium" };
|
|
595
|
-
} catch {
|
|
596
|
-
throw new BrowserNotFoundError(
|
|
597
|
-
"No Chromium browser found. Install Chrome or run: vulcn install chromium"
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
if (browserType === "firefox") {
|
|
602
|
-
try {
|
|
603
|
-
const browser = await firefox.launch({ headless });
|
|
604
|
-
return { browser, channel: "firefox" };
|
|
605
|
-
} catch {
|
|
606
|
-
throw new BrowserNotFoundError(
|
|
607
|
-
"Firefox not found. Run: vulcn install firefox"
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
if (browserType === "webkit") {
|
|
612
|
-
try {
|
|
613
|
-
const browser = await webkit.launch({ headless });
|
|
614
|
-
return { browser, channel: "webkit" };
|
|
615
|
-
} catch {
|
|
616
|
-
throw new BrowserNotFoundError(
|
|
617
|
-
"WebKit not found. Run: vulcn install webkit"
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
throw new BrowserNotFoundError(`Unknown browser type: ${browserType}`);
|
|
622
|
-
}
|
|
623
|
-
async function installBrowsers(browsers = ["chromium"]) {
|
|
624
|
-
const browserArg = browsers.join(" ");
|
|
625
|
-
await execAsync(`npx playwright install ${browserArg}`);
|
|
626
|
-
}
|
|
627
|
-
async function checkBrowsers() {
|
|
628
|
-
const results = {
|
|
629
|
-
systemChrome: false,
|
|
630
|
-
systemEdge: false,
|
|
631
|
-
playwrightChromium: false,
|
|
632
|
-
playwrightFirefox: false,
|
|
633
|
-
playwrightWebkit: false
|
|
634
|
-
};
|
|
635
|
-
try {
|
|
636
|
-
const browser = await chromium.launch({
|
|
637
|
-
channel: "chrome",
|
|
638
|
-
headless: true
|
|
639
|
-
});
|
|
640
|
-
await browser.close();
|
|
641
|
-
results.systemChrome = true;
|
|
642
|
-
} catch {
|
|
643
|
-
}
|
|
644
|
-
try {
|
|
645
|
-
const browser = await chromium.launch({
|
|
646
|
-
channel: "msedge",
|
|
647
|
-
headless: true
|
|
648
|
-
});
|
|
649
|
-
await browser.close();
|
|
650
|
-
results.systemEdge = true;
|
|
651
|
-
} catch {
|
|
652
|
-
}
|
|
653
|
-
try {
|
|
654
|
-
const browser = await chromium.launch({ headless: true });
|
|
655
|
-
await browser.close();
|
|
656
|
-
results.playwrightChromium = true;
|
|
657
|
-
} catch {
|
|
658
|
-
}
|
|
659
|
-
try {
|
|
660
|
-
const browser = await firefox.launch({ headless: true });
|
|
661
|
-
await browser.close();
|
|
662
|
-
results.playwrightFirefox = true;
|
|
663
|
-
} catch {
|
|
664
|
-
}
|
|
665
|
-
try {
|
|
666
|
-
const browser = await webkit.launch({ headless: true });
|
|
667
|
-
await browser.close();
|
|
668
|
-
results.playwrightWebkit = true;
|
|
669
|
-
} catch {
|
|
670
|
-
}
|
|
671
|
-
return results;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// src/recorder.ts
|
|
675
|
-
var Recorder = class _Recorder {
|
|
676
|
-
/**
|
|
677
|
-
* Start a new recording session
|
|
678
|
-
* Opens a browser window for the user to interact with
|
|
679
|
-
*/
|
|
680
|
-
static async start(startUrl, options = {}, config = {}) {
|
|
681
|
-
const manager = config.pluginManager ?? pluginManager;
|
|
682
|
-
const browserType = options.browser ?? "chromium";
|
|
683
|
-
const viewport = options.viewport ?? { width: 1280, height: 720 };
|
|
684
|
-
const headless = options.headless ?? false;
|
|
685
|
-
await manager.initialize();
|
|
686
|
-
const { browser } = await launchBrowser({
|
|
687
|
-
browser: browserType,
|
|
688
|
-
headless
|
|
689
|
-
});
|
|
690
|
-
const context = await browser.newContext({ viewport });
|
|
691
|
-
const page = await context.newPage();
|
|
692
|
-
await page.goto(startUrl);
|
|
693
|
-
const session = createSession({
|
|
694
|
-
name: `Recording ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
695
|
-
startUrl,
|
|
696
|
-
browser: browserType,
|
|
697
|
-
viewport
|
|
698
|
-
});
|
|
699
|
-
const startTime = Date.now();
|
|
700
|
-
const steps = [];
|
|
701
|
-
let stepCounter = 0;
|
|
702
|
-
const generateStepId = () => {
|
|
703
|
-
stepCounter++;
|
|
704
|
-
return `step_${String(stepCounter).padStart(3, "0")}`;
|
|
705
|
-
};
|
|
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 = {
|
|
726
|
-
id: generateStepId(),
|
|
727
|
-
type: "navigate",
|
|
728
|
-
url: startUrl,
|
|
729
|
-
timestamp: 0
|
|
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
|
-
);
|
|
747
|
-
return {
|
|
748
|
-
async stop() {
|
|
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
|
-
}
|
|
759
|
-
await browser.close();
|
|
760
|
-
return finalSession;
|
|
761
|
-
},
|
|
762
|
-
getSteps() {
|
|
763
|
-
return [...steps];
|
|
764
|
-
},
|
|
765
|
-
getPage() {
|
|
766
|
-
return page;
|
|
767
|
-
}
|
|
768
|
-
};
|
|
769
|
-
}
|
|
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) {
|
|
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
|
-
};
|
|
798
|
-
page.on("framenavigated", (frame) => {
|
|
799
|
-
if (frame === page.mainFrame()) {
|
|
800
|
-
const url = frame.url();
|
|
801
|
-
const lastStep = steps[steps.length - 1];
|
|
802
|
-
if (steps.length > 0 && lastStep.type === "navigate" && lastStep.url === url) {
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
addStep({
|
|
806
|
-
id: generateStepId(),
|
|
807
|
-
type: "navigate",
|
|
808
|
-
url,
|
|
809
|
-
timestamp: getTimestamp()
|
|
810
|
-
});
|
|
811
|
-
}
|
|
812
|
-
});
|
|
813
|
-
page.exposeFunction(
|
|
814
|
-
"__vulcn_record",
|
|
815
|
-
async (event) => {
|
|
816
|
-
const timestamp = getTimestamp();
|
|
817
|
-
switch (event.type) {
|
|
818
|
-
case "click": {
|
|
819
|
-
const data = event.data;
|
|
820
|
-
await addStep({
|
|
821
|
-
id: generateStepId(),
|
|
822
|
-
type: "click",
|
|
823
|
-
selector: data.selector,
|
|
824
|
-
position: { x: data.x, y: data.y },
|
|
825
|
-
timestamp
|
|
826
|
-
});
|
|
827
|
-
break;
|
|
828
|
-
}
|
|
829
|
-
case "input": {
|
|
830
|
-
const data = event.data;
|
|
831
|
-
await addStep({
|
|
832
|
-
id: generateStepId(),
|
|
833
|
-
type: "input",
|
|
834
|
-
selector: data.selector,
|
|
835
|
-
value: data.value,
|
|
836
|
-
injectable: data.injectable,
|
|
837
|
-
timestamp
|
|
838
|
-
});
|
|
839
|
-
break;
|
|
840
|
-
}
|
|
841
|
-
case "keypress": {
|
|
842
|
-
const data = event.data;
|
|
843
|
-
await addStep({
|
|
844
|
-
id: generateStepId(),
|
|
845
|
-
type: "keypress",
|
|
846
|
-
key: data.key,
|
|
847
|
-
modifiers: data.modifiers,
|
|
848
|
-
timestamp
|
|
849
|
-
});
|
|
850
|
-
break;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
);
|
|
855
|
-
page.on("load", async () => {
|
|
856
|
-
await _Recorder.injectRecordingScript(page);
|
|
857
|
-
});
|
|
858
|
-
_Recorder.injectRecordingScript(page);
|
|
859
|
-
}
|
|
860
|
-
static async injectRecordingScript(page) {
|
|
861
|
-
await page.evaluate(`
|
|
862
|
-
(function() {
|
|
863
|
-
if (window.__vulcn_injected) return;
|
|
864
|
-
window.__vulcn_injected = true;
|
|
865
|
-
|
|
866
|
-
var textInputTypes = ['text', 'password', 'email', 'search', 'url', 'tel', 'number'];
|
|
867
|
-
|
|
868
|
-
function getSelector(el) {
|
|
869
|
-
if (el.id) {
|
|
870
|
-
return '#' + CSS.escape(el.id);
|
|
871
|
-
}
|
|
872
|
-
if (el.name) {
|
|
873
|
-
var tag = el.tagName.toLowerCase();
|
|
874
|
-
var nameSelector = tag + '[name="' + el.name + '"]';
|
|
875
|
-
if (document.querySelectorAll(nameSelector).length === 1) {
|
|
876
|
-
return nameSelector;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
if (el.dataset && el.dataset.testid) {
|
|
880
|
-
return '[data-testid="' + el.dataset.testid + '"]';
|
|
881
|
-
}
|
|
882
|
-
if (el.tagName === 'INPUT' && el.type && el.name) {
|
|
883
|
-
var inputSelector = 'input[type="' + el.type + '"][name="' + el.name + '"]';
|
|
884
|
-
if (document.querySelectorAll(inputSelector).length === 1) {
|
|
885
|
-
return inputSelector;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
if (el.className && typeof el.className === 'string') {
|
|
889
|
-
var classes = el.className.trim().split(/\\s+/).filter(function(c) { return c.length > 0; });
|
|
890
|
-
if (classes.length > 0) {
|
|
891
|
-
var classSelector = el.tagName.toLowerCase() + '.' + classes.map(function(c) { return CSS.escape(c); }).join('.');
|
|
892
|
-
if (document.querySelectorAll(classSelector).length === 1) {
|
|
893
|
-
return classSelector;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
var path = [];
|
|
898
|
-
var current = el;
|
|
899
|
-
while (current && current !== document.body) {
|
|
900
|
-
var tag = current.tagName.toLowerCase();
|
|
901
|
-
var parent = current.parentElement;
|
|
902
|
-
if (parent) {
|
|
903
|
-
var siblings = Array.from(parent.children).filter(function(c) { return c.tagName === current.tagName; });
|
|
904
|
-
if (siblings.length > 1) {
|
|
905
|
-
var index = siblings.indexOf(current) + 1;
|
|
906
|
-
tag = tag + ':nth-of-type(' + index + ')';
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
path.unshift(tag);
|
|
910
|
-
current = parent;
|
|
911
|
-
}
|
|
912
|
-
return path.join(' > ');
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function getInputType(el) {
|
|
916
|
-
if (el.tagName === 'INPUT') return el.type || 'text';
|
|
917
|
-
if (el.tagName === 'TEXTAREA') return 'textarea';
|
|
918
|
-
if (el.tagName === 'SELECT') return 'select';
|
|
919
|
-
return null;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
function isTextInjectable(el) {
|
|
923
|
-
var inputType = getInputType(el);
|
|
924
|
-
if (!inputType) return false;
|
|
925
|
-
if (inputType === 'textarea') return true;
|
|
926
|
-
if (inputType === 'select') return false;
|
|
927
|
-
return textInputTypes.indexOf(inputType) !== -1;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
document.addEventListener('click', function(e) {
|
|
931
|
-
var target = e.target;
|
|
932
|
-
window.__vulcn_record({
|
|
933
|
-
type: 'click',
|
|
934
|
-
data: {
|
|
935
|
-
selector: getSelector(target),
|
|
936
|
-
x: e.clientX,
|
|
937
|
-
y: e.clientY
|
|
938
|
-
}
|
|
939
|
-
});
|
|
940
|
-
}, true);
|
|
941
|
-
|
|
942
|
-
document.addEventListener('change', function(e) {
|
|
943
|
-
var target = e.target;
|
|
944
|
-
if ('value' in target) {
|
|
945
|
-
var inputType = getInputType(target);
|
|
946
|
-
window.__vulcn_record({
|
|
947
|
-
type: 'input',
|
|
948
|
-
data: {
|
|
949
|
-
selector: getSelector(target),
|
|
950
|
-
value: target.value,
|
|
951
|
-
inputType: inputType,
|
|
952
|
-
injectable: isTextInjectable(target)
|
|
953
|
-
}
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
}, true);
|
|
957
|
-
|
|
958
|
-
document.addEventListener('keydown', function(e) {
|
|
959
|
-
if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
960
|
-
var modifiers = [];
|
|
961
|
-
if (e.ctrlKey) modifiers.push('ctrl');
|
|
962
|
-
if (e.metaKey) modifiers.push('meta');
|
|
963
|
-
if (e.altKey) modifiers.push('alt');
|
|
964
|
-
if (e.shiftKey) modifiers.push('shift');
|
|
965
|
-
|
|
966
|
-
window.__vulcn_record({
|
|
967
|
-
type: 'keypress',
|
|
968
|
-
data: {
|
|
969
|
-
key: e.key,
|
|
970
|
-
modifiers: modifiers
|
|
971
|
-
}
|
|
972
|
-
});
|
|
973
|
-
}
|
|
974
|
-
}, true);
|
|
975
|
-
})();
|
|
976
|
-
`);
|
|
977
|
-
}
|
|
978
|
-
};
|
|
979
|
-
|
|
980
|
-
// src/runner.ts
|
|
981
|
-
var Runner = class _Runner {
|
|
982
|
-
/**
|
|
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
|
|
988
|
-
*/
|
|
989
|
-
static async execute(session, options = {}, config = {}) {
|
|
990
|
-
const manager = config.pluginManager ?? pluginManager;
|
|
991
|
-
const browserType = options.browser ?? session.browser ?? "chromium";
|
|
992
|
-
const headless = options.headless ?? true;
|
|
993
|
-
const startTime = Date.now();
|
|
994
|
-
const errors = [];
|
|
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
|
-
}
|
|
1010
|
-
const { browser } = await launchBrowser({
|
|
1011
|
-
browser: browserType,
|
|
1012
|
-
headless
|
|
1013
|
-
});
|
|
1014
|
-
const context = await browser.newContext({ viewport: session.viewport });
|
|
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);
|
|
1068
|
-
try {
|
|
1069
|
-
const injectableSteps = session.steps.filter(
|
|
1070
|
-
(step) => step.type === "input" && step.injectable !== false
|
|
1071
|
-
);
|
|
1072
|
-
const allPayloads = [];
|
|
1073
|
-
for (const payloadSet of payloads) {
|
|
1074
|
-
for (const value of payloadSet.payloads) {
|
|
1075
|
-
allPayloads.push({ payloadSet, value });
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
for (const injectableStep of injectableSteps) {
|
|
1079
|
-
for (const { payloadSet, value: originalValue } of allPayloads) {
|
|
1080
|
-
try {
|
|
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(
|
|
1103
|
-
page,
|
|
1104
|
-
session,
|
|
1105
|
-
injectableStep,
|
|
1106
|
-
transformedPayload
|
|
1107
|
-
);
|
|
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);
|
|
1127
|
-
options.onFinding?.(finding);
|
|
1128
|
-
}
|
|
1129
|
-
eventFindings.length = 0;
|
|
1130
|
-
payloadsTested++;
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
errors.push(`${injectableStep.id}: ${String(err)}`);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
} finally {
|
|
1137
|
-
page.off("dialog", dialogHandler);
|
|
1138
|
-
page.off("console", consoleHandler);
|
|
1139
|
-
currentDetectContext = null;
|
|
1140
|
-
await browser.close();
|
|
1141
|
-
}
|
|
1142
|
-
let result = {
|
|
1143
|
-
findings: manager.getFindings(),
|
|
1144
|
-
stepsExecuted: session.steps.length,
|
|
1145
|
-
payloadsTested,
|
|
1146
|
-
duration: Date.now() - startTime,
|
|
1147
|
-
errors
|
|
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;
|
|
1158
|
-
}
|
|
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" });
|
|
1172
|
-
for (const step of session.steps) {
|
|
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
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
} catch {
|
|
1194
|
-
}
|
|
1195
|
-
if (step.id === targetStep.id) {
|
|
1196
|
-
await page.waitForTimeout(100);
|
|
1197
|
-
break;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
/**
|
|
1202
|
-
* Basic reflection check - fallback when no detection plugin is loaded
|
|
1203
|
-
*/
|
|
1204
|
-
static async checkReflection(page, step, payloadSet, payloadValue) {
|
|
1205
|
-
const content = await page.content();
|
|
1206
|
-
for (const pattern of payloadSet.detectPatterns) {
|
|
1207
|
-
if (pattern.test(content)) {
|
|
1208
|
-
return {
|
|
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`,
|
|
1213
|
-
stepId: step.id,
|
|
1214
|
-
payload: payloadValue,
|
|
1215
|
-
url: page.url(),
|
|
1216
|
-
evidence: content.match(pattern)?.[0]?.slice(0, 200)
|
|
1217
|
-
};
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
if (content.includes(payloadValue)) {
|
|
1221
|
-
return {
|
|
1222
|
-
type: payloadSet.category,
|
|
1223
|
-
severity: "medium",
|
|
1224
|
-
title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
|
|
1225
|
-
description: `Payload was reflected in page without encoding`,
|
|
1226
|
-
stepId: step.id,
|
|
1227
|
-
payload: payloadValue,
|
|
1228
|
-
url: page.url()
|
|
1229
|
-
};
|
|
1230
|
-
}
|
|
1231
|
-
return void 0;
|
|
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
|
-
}
|
|
1252
|
-
};
|
|
1253
586
|
export {
|
|
1254
|
-
BrowserNotFoundError,
|
|
1255
587
|
DRIVER_API_VERSION,
|
|
1256
588
|
DriverManager,
|
|
1257
589
|
PLUGIN_API_VERSION,
|
|
1258
590
|
PluginManager,
|
|
1259
|
-
Recorder,
|
|
1260
|
-
Runner,
|
|
1261
|
-
SessionSchema,
|
|
1262
|
-
StepSchema,
|
|
1263
|
-
checkBrowsers,
|
|
1264
|
-
createSession,
|
|
1265
591
|
driverManager,
|
|
1266
|
-
|
|
1267
|
-
launchBrowser,
|
|
1268
|
-
parseSession,
|
|
1269
|
-
pluginManager,
|
|
1270
|
-
serializeSession
|
|
592
|
+
pluginManager
|
|
1271
593
|
};
|
|
1272
594
|
//# sourceMappingURL=index.js.map
|