@vulcn/driver-browser 0.1.0 → 0.1.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/LICENSE +662 -0
- package/dist/index.cjs +358 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -2
- package/dist/index.d.ts +41 -2
- package/dist/index.js +357 -10
- package/dist/index.js.map +1 -1
- package/package.json +24 -15
package/dist/index.js
CHANGED
|
@@ -407,18 +407,20 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
407
407
|
const dialogHandler = async (dialog) => {
|
|
408
408
|
if (currentPayloadInfo) {
|
|
409
409
|
const message = dialog.message();
|
|
410
|
-
|
|
410
|
+
const dialogType = dialog.type();
|
|
411
|
+
if (dialogType !== "beforeunload") {
|
|
411
412
|
eventFindings.push({
|
|
412
413
|
type: "xss",
|
|
413
414
|
severity: "high",
|
|
414
|
-
title:
|
|
415
|
-
description: `JavaScript dialog was triggered by payload injection`,
|
|
415
|
+
title: `XSS Confirmed - ${dialogType}() triggered`,
|
|
416
|
+
description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
|
|
416
417
|
stepId: currentPayloadInfo.stepId,
|
|
417
418
|
payload: currentPayloadInfo.payloadValue,
|
|
418
419
|
url: page.url(),
|
|
419
|
-
evidence: `Dialog
|
|
420
|
+
evidence: `Dialog type: ${dialogType}, Message: ${message}`,
|
|
420
421
|
metadata: {
|
|
421
|
-
dialogType
|
|
422
|
+
dialogType,
|
|
423
|
+
dialogMessage: message,
|
|
422
424
|
detectionMethod: "dialog"
|
|
423
425
|
}
|
|
424
426
|
});
|
|
@@ -514,22 +516,45 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
514
516
|
}
|
|
515
517
|
/**
|
|
516
518
|
* Replay session steps with payload injected at target step
|
|
519
|
+
*
|
|
520
|
+
* IMPORTANT: We replay ALL steps, not just up to the injectable step.
|
|
521
|
+
* The injection replaces the input value, but subsequent steps (like
|
|
522
|
+
* clicking submit) must still execute so the payload reaches the server
|
|
523
|
+
* and gets reflected back in the response.
|
|
517
524
|
*/
|
|
518
525
|
static async replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
|
|
519
526
|
await page.goto(startUrl, { waitUntil: "domcontentloaded" });
|
|
527
|
+
let injected = false;
|
|
520
528
|
for (const step of session.steps) {
|
|
521
529
|
const browserStep = step;
|
|
522
530
|
try {
|
|
523
531
|
switch (browserStep.type) {
|
|
524
532
|
case "browser.navigate":
|
|
533
|
+
if (injected && browserStep.url.includes("sid=")) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
525
536
|
await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
|
|
526
537
|
break;
|
|
527
538
|
case "browser.click":
|
|
528
|
-
|
|
539
|
+
if (injected) {
|
|
540
|
+
await Promise.all([
|
|
541
|
+
page.waitForNavigation({
|
|
542
|
+
waitUntil: "domcontentloaded",
|
|
543
|
+
timeout: 5e3
|
|
544
|
+
}).catch(() => {
|
|
545
|
+
}),
|
|
546
|
+
page.click(browserStep.selector, { timeout: 5e3 })
|
|
547
|
+
]);
|
|
548
|
+
} else {
|
|
549
|
+
await page.click(browserStep.selector, { timeout: 5e3 });
|
|
550
|
+
}
|
|
529
551
|
break;
|
|
530
552
|
case "browser.input": {
|
|
531
553
|
const value = step.id === targetStep.id ? payloadValue : browserStep.value;
|
|
532
554
|
await page.fill(browserStep.selector, value, { timeout: 5e3 });
|
|
555
|
+
if (step.id === targetStep.id) {
|
|
556
|
+
injected = true;
|
|
557
|
+
}
|
|
533
558
|
break;
|
|
534
559
|
}
|
|
535
560
|
case "browser.keypress": {
|
|
@@ -564,11 +589,8 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
564
589
|
}
|
|
565
590
|
} catch {
|
|
566
591
|
}
|
|
567
|
-
if (step.id === targetStep.id) {
|
|
568
|
-
await page.waitForTimeout(100);
|
|
569
|
-
break;
|
|
570
|
-
}
|
|
571
592
|
}
|
|
593
|
+
await page.waitForTimeout(500);
|
|
572
594
|
}
|
|
573
595
|
/**
|
|
574
596
|
* Check for payload reflection in page content
|
|
@@ -623,6 +645,318 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
623
645
|
}
|
|
624
646
|
};
|
|
625
647
|
|
|
648
|
+
// src/crawler.ts
|
|
649
|
+
var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
650
|
+
"text",
|
|
651
|
+
"search",
|
|
652
|
+
"url",
|
|
653
|
+
"email",
|
|
654
|
+
"tel",
|
|
655
|
+
"password",
|
|
656
|
+
"textarea",
|
|
657
|
+
""
|
|
658
|
+
]);
|
|
659
|
+
var CRAWL_DEFAULTS = {
|
|
660
|
+
maxDepth: 2,
|
|
661
|
+
maxPages: 20,
|
|
662
|
+
pageTimeout: 1e4,
|
|
663
|
+
sameOrigin: true
|
|
664
|
+
};
|
|
665
|
+
async function crawlAndBuildSessions(config, options = {}) {
|
|
666
|
+
const opts = { ...CRAWL_DEFAULTS, ...options };
|
|
667
|
+
const startUrl = config.startUrl;
|
|
668
|
+
let normalizedUrl;
|
|
669
|
+
try {
|
|
670
|
+
normalizedUrl = new URL(startUrl);
|
|
671
|
+
} catch {
|
|
672
|
+
throw new Error(`Invalid URL: ${startUrl}`);
|
|
673
|
+
}
|
|
674
|
+
const origin = normalizedUrl.origin;
|
|
675
|
+
const visited = /* @__PURE__ */ new Set();
|
|
676
|
+
const allForms = [];
|
|
677
|
+
const queue = [[normalizedUrl.href, 0]];
|
|
678
|
+
const { browser } = await launchBrowser({
|
|
679
|
+
browser: config.browser ?? "chromium",
|
|
680
|
+
headless: config.headless ?? true
|
|
681
|
+
});
|
|
682
|
+
const context = await browser.newContext({
|
|
683
|
+
viewport: config.viewport ?? { width: 1280, height: 720 }
|
|
684
|
+
});
|
|
685
|
+
try {
|
|
686
|
+
while (queue.length > 0 && visited.size < opts.maxPages) {
|
|
687
|
+
const [url, depth] = queue.shift();
|
|
688
|
+
const normalizedPageUrl = normalizeUrl(url);
|
|
689
|
+
if (visited.has(normalizedPageUrl)) continue;
|
|
690
|
+
visited.add(normalizedPageUrl);
|
|
691
|
+
console.log(`[crawler] [depth=${depth}] Crawling: ${normalizedPageUrl}`);
|
|
692
|
+
const page = await context.newPage();
|
|
693
|
+
try {
|
|
694
|
+
await page.goto(normalizedPageUrl, {
|
|
695
|
+
waitUntil: "domcontentloaded",
|
|
696
|
+
timeout: opts.pageTimeout
|
|
697
|
+
});
|
|
698
|
+
await page.waitForTimeout(1e3);
|
|
699
|
+
const forms = await discoverForms(page, normalizedPageUrl);
|
|
700
|
+
allForms.push(...forms);
|
|
701
|
+
const injectableCount = forms.reduce(
|
|
702
|
+
(s, f) => s + f.inputs.filter((i) => i.injectable).length,
|
|
703
|
+
0
|
|
704
|
+
);
|
|
705
|
+
console.log(
|
|
706
|
+
`[crawler] Found ${forms.length} form(s), ${injectableCount} injectable input(s)`
|
|
707
|
+
);
|
|
708
|
+
opts.onPageCrawled?.(normalizedPageUrl, forms.length);
|
|
709
|
+
if (depth < opts.maxDepth) {
|
|
710
|
+
const links = await discoverLinks(page, origin, opts.sameOrigin);
|
|
711
|
+
for (const link of links) {
|
|
712
|
+
const normalizedLink = normalizeUrl(link);
|
|
713
|
+
if (!visited.has(normalizedLink)) {
|
|
714
|
+
queue.push([normalizedLink, depth + 1]);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
console.log(`[crawler] Found ${links.length} link(s) to follow`);
|
|
718
|
+
}
|
|
719
|
+
} catch (err) {
|
|
720
|
+
console.warn(
|
|
721
|
+
`[crawler] Failed: ${err instanceof Error ? err.message : String(err)}`
|
|
722
|
+
);
|
|
723
|
+
} finally {
|
|
724
|
+
await page.close();
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} finally {
|
|
728
|
+
await browser.close();
|
|
729
|
+
}
|
|
730
|
+
console.log(
|
|
731
|
+
`[crawler] Complete: ${visited.size} page(s), ${allForms.length} form(s)`
|
|
732
|
+
);
|
|
733
|
+
return buildSessions(allForms);
|
|
734
|
+
}
|
|
735
|
+
async function discoverForms(page, pageUrl) {
|
|
736
|
+
const forms = [];
|
|
737
|
+
const explicitForms = await page.evaluate(() => {
|
|
738
|
+
const results = [];
|
|
739
|
+
const formElements = document.querySelectorAll("form");
|
|
740
|
+
formElements.forEach((form, formIndex) => {
|
|
741
|
+
const inputs = [];
|
|
742
|
+
const inputEls = form.querySelectorAll(
|
|
743
|
+
'input, textarea, [contenteditable="true"]'
|
|
744
|
+
);
|
|
745
|
+
inputEls.forEach((input, inputIndex) => {
|
|
746
|
+
const el = input;
|
|
747
|
+
const type = el.tagName.toLowerCase() === "textarea" ? "textarea" : el.getAttribute("type") || "text";
|
|
748
|
+
const name = el.name || el.id || `input-${inputIndex}`;
|
|
749
|
+
let selector = "";
|
|
750
|
+
if (el.id) {
|
|
751
|
+
selector = `#${CSS.escape(el.id)}`;
|
|
752
|
+
} else if (el.name) {
|
|
753
|
+
selector = `form:nth-of-type(${formIndex + 1}) [name="${CSS.escape(el.name)}"]`;
|
|
754
|
+
} else {
|
|
755
|
+
selector = `form:nth-of-type(${formIndex + 1}) ${el.tagName.toLowerCase()}:nth-of-type(${inputIndex + 1})`;
|
|
756
|
+
}
|
|
757
|
+
inputs.push({
|
|
758
|
+
selector,
|
|
759
|
+
type,
|
|
760
|
+
name,
|
|
761
|
+
placeholder: el.placeholder || ""
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
let submitSelector = null;
|
|
765
|
+
const submitBtn = form.querySelector('button[type="submit"], input[type="submit"]') || form.querySelector("button:not([type])") || form.querySelector('button, input[type="button"]');
|
|
766
|
+
if (submitBtn) {
|
|
767
|
+
const btn = submitBtn;
|
|
768
|
+
if (btn.id) {
|
|
769
|
+
submitSelector = `#${CSS.escape(btn.id)}`;
|
|
770
|
+
} else {
|
|
771
|
+
const tag = btn.tagName.toLowerCase();
|
|
772
|
+
const type = btn.getAttribute("type");
|
|
773
|
+
if (type) {
|
|
774
|
+
submitSelector = `form:nth-of-type(${formIndex + 1}) ${tag}[type="${type}"]`;
|
|
775
|
+
} else {
|
|
776
|
+
submitSelector = `form:nth-of-type(${formIndex + 1}) ${tag}`;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
results.push({
|
|
781
|
+
formIndex,
|
|
782
|
+
action: form.action || "",
|
|
783
|
+
method: (form.method || "GET").toUpperCase(),
|
|
784
|
+
inputs,
|
|
785
|
+
submitSelector
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
return results;
|
|
789
|
+
});
|
|
790
|
+
for (const form of explicitForms) {
|
|
791
|
+
if (form.inputs.length === 0) continue;
|
|
792
|
+
forms.push({
|
|
793
|
+
pageUrl,
|
|
794
|
+
formSelector: `form:nth-of-type(${form.formIndex + 1})`,
|
|
795
|
+
action: form.action,
|
|
796
|
+
method: form.method,
|
|
797
|
+
inputs: form.inputs.map((input) => ({
|
|
798
|
+
selector: input.selector,
|
|
799
|
+
type: input.type,
|
|
800
|
+
name: input.name,
|
|
801
|
+
injectable: INJECTABLE_INPUT_TYPES.has(input.type.toLowerCase()),
|
|
802
|
+
placeholder: input.placeholder || void 0
|
|
803
|
+
})),
|
|
804
|
+
submitSelector: form.submitSelector
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const standaloneInputs = await page.evaluate(() => {
|
|
808
|
+
const results = [];
|
|
809
|
+
const allInputs = document.querySelectorAll(
|
|
810
|
+
'input:not(form input), textarea:not(form textarea), [contenteditable="true"]:not(form [contenteditable])'
|
|
811
|
+
);
|
|
812
|
+
allInputs.forEach((input) => {
|
|
813
|
+
const el = input;
|
|
814
|
+
const type = el.tagName.toLowerCase() === "textarea" ? "textarea" : el.getAttribute("type") || "text";
|
|
815
|
+
const name = el.name || el.id || "";
|
|
816
|
+
let selector = "";
|
|
817
|
+
if (el.id) {
|
|
818
|
+
selector = `#${CSS.escape(el.id)}`;
|
|
819
|
+
} else if (el.name) {
|
|
820
|
+
selector = `[name="${CSS.escape(el.name)}"]`;
|
|
821
|
+
} else {
|
|
822
|
+
selector = `${el.tagName.toLowerCase()}[type="${type}"]`;
|
|
823
|
+
}
|
|
824
|
+
let nearbyButtonSelector = null;
|
|
825
|
+
const parent = el.parentElement;
|
|
826
|
+
if (parent) {
|
|
827
|
+
const btn = parent.querySelector("button") || parent.querySelector('input[type="submit"]') || parent.querySelector('input[type="button"]');
|
|
828
|
+
if (btn) {
|
|
829
|
+
const btnEl = btn;
|
|
830
|
+
if (btnEl.id) {
|
|
831
|
+
nearbyButtonSelector = `#${CSS.escape(btnEl.id)}`;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
results.push({
|
|
836
|
+
selector,
|
|
837
|
+
type,
|
|
838
|
+
name,
|
|
839
|
+
placeholder: el.placeholder || "",
|
|
840
|
+
nearbyButtonSelector
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
return results;
|
|
844
|
+
});
|
|
845
|
+
for (const input of standaloneInputs) {
|
|
846
|
+
if (!INJECTABLE_INPUT_TYPES.has(input.type.toLowerCase())) continue;
|
|
847
|
+
forms.push({
|
|
848
|
+
pageUrl,
|
|
849
|
+
formSelector: "(standalone)",
|
|
850
|
+
action: pageUrl,
|
|
851
|
+
method: "GET",
|
|
852
|
+
inputs: [
|
|
853
|
+
{
|
|
854
|
+
selector: input.selector,
|
|
855
|
+
type: input.type,
|
|
856
|
+
name: input.name,
|
|
857
|
+
injectable: true,
|
|
858
|
+
placeholder: input.placeholder || void 0
|
|
859
|
+
}
|
|
860
|
+
],
|
|
861
|
+
submitSelector: input.nearbyButtonSelector
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
return forms;
|
|
865
|
+
}
|
|
866
|
+
async function discoverLinks(page, origin, sameOrigin) {
|
|
867
|
+
const links = await page.evaluate(() => {
|
|
868
|
+
return Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((href) => href.startsWith("http"));
|
|
869
|
+
});
|
|
870
|
+
if (sameOrigin) {
|
|
871
|
+
return links.filter((link) => {
|
|
872
|
+
try {
|
|
873
|
+
return new URL(link).origin === origin;
|
|
874
|
+
} catch {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
return links;
|
|
880
|
+
}
|
|
881
|
+
function buildSessions(forms) {
|
|
882
|
+
const targetForms = forms.filter((f) => f.inputs.some((i) => i.injectable));
|
|
883
|
+
return targetForms.map((form, idx) => buildSessionForForm(form, idx));
|
|
884
|
+
}
|
|
885
|
+
function buildSessionForForm(form, index) {
|
|
886
|
+
const steps = [];
|
|
887
|
+
let stepNum = 1;
|
|
888
|
+
steps.push({
|
|
889
|
+
id: `step-${stepNum++}`,
|
|
890
|
+
type: "browser.navigate",
|
|
891
|
+
url: form.pageUrl,
|
|
892
|
+
timestamp: Date.now()
|
|
893
|
+
});
|
|
894
|
+
const injectableInputs = form.inputs.filter((i) => i.injectable);
|
|
895
|
+
for (const input of injectableInputs) {
|
|
896
|
+
steps.push({
|
|
897
|
+
id: `step-${stepNum++}`,
|
|
898
|
+
type: "browser.input",
|
|
899
|
+
selector: input.selector,
|
|
900
|
+
value: "test",
|
|
901
|
+
injectable: true,
|
|
902
|
+
timestamp: Date.now() + stepNum * 100
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
if (form.submitSelector) {
|
|
906
|
+
steps.push({
|
|
907
|
+
id: `step-${stepNum++}`,
|
|
908
|
+
type: "browser.click",
|
|
909
|
+
selector: form.submitSelector,
|
|
910
|
+
timestamp: Date.now() + stepNum * 100
|
|
911
|
+
});
|
|
912
|
+
} else {
|
|
913
|
+
steps.push({
|
|
914
|
+
id: `step-${stepNum++}`,
|
|
915
|
+
type: "browser.keypress",
|
|
916
|
+
key: "Enter",
|
|
917
|
+
timestamp: Date.now() + stepNum * 100
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
const inputNames = injectableInputs.map((i) => i.name || i.type).join(", ");
|
|
921
|
+
const pagePath = (() => {
|
|
922
|
+
try {
|
|
923
|
+
return new URL(form.pageUrl).pathname;
|
|
924
|
+
} catch {
|
|
925
|
+
return form.pageUrl;
|
|
926
|
+
}
|
|
927
|
+
})();
|
|
928
|
+
return {
|
|
929
|
+
name: `Crawl: ${pagePath} \u2014 form ${index + 1} (${inputNames})`,
|
|
930
|
+
driver: "browser",
|
|
931
|
+
driverConfig: {
|
|
932
|
+
startUrl: form.pageUrl,
|
|
933
|
+
browser: "chromium",
|
|
934
|
+
headless: true,
|
|
935
|
+
viewport: { width: 1280, height: 720 }
|
|
936
|
+
},
|
|
937
|
+
steps,
|
|
938
|
+
metadata: {
|
|
939
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
940
|
+
version: "0.3.0",
|
|
941
|
+
source: "crawler",
|
|
942
|
+
formAction: form.action,
|
|
943
|
+
formMethod: form.method
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function normalizeUrl(url) {
|
|
948
|
+
try {
|
|
949
|
+
const parsed = new URL(url);
|
|
950
|
+
parsed.hash = "";
|
|
951
|
+
if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) {
|
|
952
|
+
parsed.pathname = parsed.pathname.slice(0, -1);
|
|
953
|
+
}
|
|
954
|
+
return parsed.href;
|
|
955
|
+
} catch {
|
|
956
|
+
return url;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
626
960
|
// src/index.ts
|
|
627
961
|
var configSchema = z.object({
|
|
628
962
|
/** Starting URL for recording */
|
|
@@ -692,6 +1026,18 @@ var recorderDriver = {
|
|
|
692
1026
|
async start(config, options) {
|
|
693
1027
|
const parsedConfig = configSchema.parse(config);
|
|
694
1028
|
return BrowserRecorder.start(parsedConfig, options);
|
|
1029
|
+
},
|
|
1030
|
+
async crawl(config, options) {
|
|
1031
|
+
const parsedConfig = configSchema.parse(config);
|
|
1032
|
+
return crawlAndBuildSessions(
|
|
1033
|
+
{
|
|
1034
|
+
startUrl: parsedConfig.startUrl ?? "",
|
|
1035
|
+
browser: parsedConfig.browser,
|
|
1036
|
+
headless: parsedConfig.headless,
|
|
1037
|
+
viewport: parsedConfig.viewport
|
|
1038
|
+
},
|
|
1039
|
+
options
|
|
1040
|
+
);
|
|
695
1041
|
}
|
|
696
1042
|
};
|
|
697
1043
|
var runnerDriver = {
|
|
@@ -717,6 +1063,7 @@ export {
|
|
|
717
1063
|
BrowserStepSchema,
|
|
718
1064
|
checkBrowsers,
|
|
719
1065
|
configSchema,
|
|
1066
|
+
crawlAndBuildSessions,
|
|
720
1067
|
index_default as default,
|
|
721
1068
|
installBrowsers,
|
|
722
1069
|
launchBrowser
|