@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.cjs
CHANGED
|
@@ -26,6 +26,7 @@ __export(index_exports, {
|
|
|
26
26
|
BrowserStepSchema: () => BrowserStepSchema,
|
|
27
27
|
checkBrowsers: () => checkBrowsers,
|
|
28
28
|
configSchema: () => configSchema,
|
|
29
|
+
crawlAndBuildSessions: () => crawlAndBuildSessions,
|
|
29
30
|
default: () => index_default,
|
|
30
31
|
installBrowsers: () => installBrowsers,
|
|
31
32
|
launchBrowser: () => launchBrowser
|
|
@@ -439,18 +440,20 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
439
440
|
const dialogHandler = async (dialog) => {
|
|
440
441
|
if (currentPayloadInfo) {
|
|
441
442
|
const message = dialog.message();
|
|
442
|
-
|
|
443
|
+
const dialogType = dialog.type();
|
|
444
|
+
if (dialogType !== "beforeunload") {
|
|
443
445
|
eventFindings.push({
|
|
444
446
|
type: "xss",
|
|
445
447
|
severity: "high",
|
|
446
|
-
title:
|
|
447
|
-
description: `JavaScript dialog was triggered by payload injection`,
|
|
448
|
+
title: `XSS Confirmed - ${dialogType}() triggered`,
|
|
449
|
+
description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
|
|
448
450
|
stepId: currentPayloadInfo.stepId,
|
|
449
451
|
payload: currentPayloadInfo.payloadValue,
|
|
450
452
|
url: page.url(),
|
|
451
|
-
evidence: `Dialog
|
|
453
|
+
evidence: `Dialog type: ${dialogType}, Message: ${message}`,
|
|
452
454
|
metadata: {
|
|
453
|
-
dialogType
|
|
455
|
+
dialogType,
|
|
456
|
+
dialogMessage: message,
|
|
454
457
|
detectionMethod: "dialog"
|
|
455
458
|
}
|
|
456
459
|
});
|
|
@@ -546,22 +549,45 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
546
549
|
}
|
|
547
550
|
/**
|
|
548
551
|
* Replay session steps with payload injected at target step
|
|
552
|
+
*
|
|
553
|
+
* IMPORTANT: We replay ALL steps, not just up to the injectable step.
|
|
554
|
+
* The injection replaces the input value, but subsequent steps (like
|
|
555
|
+
* clicking submit) must still execute so the payload reaches the server
|
|
556
|
+
* and gets reflected back in the response.
|
|
549
557
|
*/
|
|
550
558
|
static async replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
|
|
551
559
|
await page.goto(startUrl, { waitUntil: "domcontentloaded" });
|
|
560
|
+
let injected = false;
|
|
552
561
|
for (const step of session.steps) {
|
|
553
562
|
const browserStep = step;
|
|
554
563
|
try {
|
|
555
564
|
switch (browserStep.type) {
|
|
556
565
|
case "browser.navigate":
|
|
566
|
+
if (injected && browserStep.url.includes("sid=")) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
557
569
|
await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
|
|
558
570
|
break;
|
|
559
571
|
case "browser.click":
|
|
560
|
-
|
|
572
|
+
if (injected) {
|
|
573
|
+
await Promise.all([
|
|
574
|
+
page.waitForNavigation({
|
|
575
|
+
waitUntil: "domcontentloaded",
|
|
576
|
+
timeout: 5e3
|
|
577
|
+
}).catch(() => {
|
|
578
|
+
}),
|
|
579
|
+
page.click(browserStep.selector, { timeout: 5e3 })
|
|
580
|
+
]);
|
|
581
|
+
} else {
|
|
582
|
+
await page.click(browserStep.selector, { timeout: 5e3 });
|
|
583
|
+
}
|
|
561
584
|
break;
|
|
562
585
|
case "browser.input": {
|
|
563
586
|
const value = step.id === targetStep.id ? payloadValue : browserStep.value;
|
|
564
587
|
await page.fill(browserStep.selector, value, { timeout: 5e3 });
|
|
588
|
+
if (step.id === targetStep.id) {
|
|
589
|
+
injected = true;
|
|
590
|
+
}
|
|
565
591
|
break;
|
|
566
592
|
}
|
|
567
593
|
case "browser.keypress": {
|
|
@@ -596,11 +622,8 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
596
622
|
}
|
|
597
623
|
} catch {
|
|
598
624
|
}
|
|
599
|
-
if (step.id === targetStep.id) {
|
|
600
|
-
await page.waitForTimeout(100);
|
|
601
|
-
break;
|
|
602
|
-
}
|
|
603
625
|
}
|
|
626
|
+
await page.waitForTimeout(500);
|
|
604
627
|
}
|
|
605
628
|
/**
|
|
606
629
|
* Check for payload reflection in page content
|
|
@@ -655,6 +678,318 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
655
678
|
}
|
|
656
679
|
};
|
|
657
680
|
|
|
681
|
+
// src/crawler.ts
|
|
682
|
+
var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
683
|
+
"text",
|
|
684
|
+
"search",
|
|
685
|
+
"url",
|
|
686
|
+
"email",
|
|
687
|
+
"tel",
|
|
688
|
+
"password",
|
|
689
|
+
"textarea",
|
|
690
|
+
""
|
|
691
|
+
]);
|
|
692
|
+
var CRAWL_DEFAULTS = {
|
|
693
|
+
maxDepth: 2,
|
|
694
|
+
maxPages: 20,
|
|
695
|
+
pageTimeout: 1e4,
|
|
696
|
+
sameOrigin: true
|
|
697
|
+
};
|
|
698
|
+
async function crawlAndBuildSessions(config, options = {}) {
|
|
699
|
+
const opts = { ...CRAWL_DEFAULTS, ...options };
|
|
700
|
+
const startUrl = config.startUrl;
|
|
701
|
+
let normalizedUrl;
|
|
702
|
+
try {
|
|
703
|
+
normalizedUrl = new URL(startUrl);
|
|
704
|
+
} catch {
|
|
705
|
+
throw new Error(`Invalid URL: ${startUrl}`);
|
|
706
|
+
}
|
|
707
|
+
const origin = normalizedUrl.origin;
|
|
708
|
+
const visited = /* @__PURE__ */ new Set();
|
|
709
|
+
const allForms = [];
|
|
710
|
+
const queue = [[normalizedUrl.href, 0]];
|
|
711
|
+
const { browser } = await launchBrowser({
|
|
712
|
+
browser: config.browser ?? "chromium",
|
|
713
|
+
headless: config.headless ?? true
|
|
714
|
+
});
|
|
715
|
+
const context = await browser.newContext({
|
|
716
|
+
viewport: config.viewport ?? { width: 1280, height: 720 }
|
|
717
|
+
});
|
|
718
|
+
try {
|
|
719
|
+
while (queue.length > 0 && visited.size < opts.maxPages) {
|
|
720
|
+
const [url, depth] = queue.shift();
|
|
721
|
+
const normalizedPageUrl = normalizeUrl(url);
|
|
722
|
+
if (visited.has(normalizedPageUrl)) continue;
|
|
723
|
+
visited.add(normalizedPageUrl);
|
|
724
|
+
console.log(`[crawler] [depth=${depth}] Crawling: ${normalizedPageUrl}`);
|
|
725
|
+
const page = await context.newPage();
|
|
726
|
+
try {
|
|
727
|
+
await page.goto(normalizedPageUrl, {
|
|
728
|
+
waitUntil: "domcontentloaded",
|
|
729
|
+
timeout: opts.pageTimeout
|
|
730
|
+
});
|
|
731
|
+
await page.waitForTimeout(1e3);
|
|
732
|
+
const forms = await discoverForms(page, normalizedPageUrl);
|
|
733
|
+
allForms.push(...forms);
|
|
734
|
+
const injectableCount = forms.reduce(
|
|
735
|
+
(s, f) => s + f.inputs.filter((i) => i.injectable).length,
|
|
736
|
+
0
|
|
737
|
+
);
|
|
738
|
+
console.log(
|
|
739
|
+
`[crawler] Found ${forms.length} form(s), ${injectableCount} injectable input(s)`
|
|
740
|
+
);
|
|
741
|
+
opts.onPageCrawled?.(normalizedPageUrl, forms.length);
|
|
742
|
+
if (depth < opts.maxDepth) {
|
|
743
|
+
const links = await discoverLinks(page, origin, opts.sameOrigin);
|
|
744
|
+
for (const link of links) {
|
|
745
|
+
const normalizedLink = normalizeUrl(link);
|
|
746
|
+
if (!visited.has(normalizedLink)) {
|
|
747
|
+
queue.push([normalizedLink, depth + 1]);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
console.log(`[crawler] Found ${links.length} link(s) to follow`);
|
|
751
|
+
}
|
|
752
|
+
} catch (err) {
|
|
753
|
+
console.warn(
|
|
754
|
+
`[crawler] Failed: ${err instanceof Error ? err.message : String(err)}`
|
|
755
|
+
);
|
|
756
|
+
} finally {
|
|
757
|
+
await page.close();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} finally {
|
|
761
|
+
await browser.close();
|
|
762
|
+
}
|
|
763
|
+
console.log(
|
|
764
|
+
`[crawler] Complete: ${visited.size} page(s), ${allForms.length} form(s)`
|
|
765
|
+
);
|
|
766
|
+
return buildSessions(allForms);
|
|
767
|
+
}
|
|
768
|
+
async function discoverForms(page, pageUrl) {
|
|
769
|
+
const forms = [];
|
|
770
|
+
const explicitForms = await page.evaluate(() => {
|
|
771
|
+
const results = [];
|
|
772
|
+
const formElements = document.querySelectorAll("form");
|
|
773
|
+
formElements.forEach((form, formIndex) => {
|
|
774
|
+
const inputs = [];
|
|
775
|
+
const inputEls = form.querySelectorAll(
|
|
776
|
+
'input, textarea, [contenteditable="true"]'
|
|
777
|
+
);
|
|
778
|
+
inputEls.forEach((input, inputIndex) => {
|
|
779
|
+
const el = input;
|
|
780
|
+
const type = el.tagName.toLowerCase() === "textarea" ? "textarea" : el.getAttribute("type") || "text";
|
|
781
|
+
const name = el.name || el.id || `input-${inputIndex}`;
|
|
782
|
+
let selector = "";
|
|
783
|
+
if (el.id) {
|
|
784
|
+
selector = `#${CSS.escape(el.id)}`;
|
|
785
|
+
} else if (el.name) {
|
|
786
|
+
selector = `form:nth-of-type(${formIndex + 1}) [name="${CSS.escape(el.name)}"]`;
|
|
787
|
+
} else {
|
|
788
|
+
selector = `form:nth-of-type(${formIndex + 1}) ${el.tagName.toLowerCase()}:nth-of-type(${inputIndex + 1})`;
|
|
789
|
+
}
|
|
790
|
+
inputs.push({
|
|
791
|
+
selector,
|
|
792
|
+
type,
|
|
793
|
+
name,
|
|
794
|
+
placeholder: el.placeholder || ""
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
let submitSelector = null;
|
|
798
|
+
const submitBtn = form.querySelector('button[type="submit"], input[type="submit"]') || form.querySelector("button:not([type])") || form.querySelector('button, input[type="button"]');
|
|
799
|
+
if (submitBtn) {
|
|
800
|
+
const btn = submitBtn;
|
|
801
|
+
if (btn.id) {
|
|
802
|
+
submitSelector = `#${CSS.escape(btn.id)}`;
|
|
803
|
+
} else {
|
|
804
|
+
const tag = btn.tagName.toLowerCase();
|
|
805
|
+
const type = btn.getAttribute("type");
|
|
806
|
+
if (type) {
|
|
807
|
+
submitSelector = `form:nth-of-type(${formIndex + 1}) ${tag}[type="${type}"]`;
|
|
808
|
+
} else {
|
|
809
|
+
submitSelector = `form:nth-of-type(${formIndex + 1}) ${tag}`;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
results.push({
|
|
814
|
+
formIndex,
|
|
815
|
+
action: form.action || "",
|
|
816
|
+
method: (form.method || "GET").toUpperCase(),
|
|
817
|
+
inputs,
|
|
818
|
+
submitSelector
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
return results;
|
|
822
|
+
});
|
|
823
|
+
for (const form of explicitForms) {
|
|
824
|
+
if (form.inputs.length === 0) continue;
|
|
825
|
+
forms.push({
|
|
826
|
+
pageUrl,
|
|
827
|
+
formSelector: `form:nth-of-type(${form.formIndex + 1})`,
|
|
828
|
+
action: form.action,
|
|
829
|
+
method: form.method,
|
|
830
|
+
inputs: form.inputs.map((input) => ({
|
|
831
|
+
selector: input.selector,
|
|
832
|
+
type: input.type,
|
|
833
|
+
name: input.name,
|
|
834
|
+
injectable: INJECTABLE_INPUT_TYPES.has(input.type.toLowerCase()),
|
|
835
|
+
placeholder: input.placeholder || void 0
|
|
836
|
+
})),
|
|
837
|
+
submitSelector: form.submitSelector
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const standaloneInputs = await page.evaluate(() => {
|
|
841
|
+
const results = [];
|
|
842
|
+
const allInputs = document.querySelectorAll(
|
|
843
|
+
'input:not(form input), textarea:not(form textarea), [contenteditable="true"]:not(form [contenteditable])'
|
|
844
|
+
);
|
|
845
|
+
allInputs.forEach((input) => {
|
|
846
|
+
const el = input;
|
|
847
|
+
const type = el.tagName.toLowerCase() === "textarea" ? "textarea" : el.getAttribute("type") || "text";
|
|
848
|
+
const name = el.name || el.id || "";
|
|
849
|
+
let selector = "";
|
|
850
|
+
if (el.id) {
|
|
851
|
+
selector = `#${CSS.escape(el.id)}`;
|
|
852
|
+
} else if (el.name) {
|
|
853
|
+
selector = `[name="${CSS.escape(el.name)}"]`;
|
|
854
|
+
} else {
|
|
855
|
+
selector = `${el.tagName.toLowerCase()}[type="${type}"]`;
|
|
856
|
+
}
|
|
857
|
+
let nearbyButtonSelector = null;
|
|
858
|
+
const parent = el.parentElement;
|
|
859
|
+
if (parent) {
|
|
860
|
+
const btn = parent.querySelector("button") || parent.querySelector('input[type="submit"]') || parent.querySelector('input[type="button"]');
|
|
861
|
+
if (btn) {
|
|
862
|
+
const btnEl = btn;
|
|
863
|
+
if (btnEl.id) {
|
|
864
|
+
nearbyButtonSelector = `#${CSS.escape(btnEl.id)}`;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
results.push({
|
|
869
|
+
selector,
|
|
870
|
+
type,
|
|
871
|
+
name,
|
|
872
|
+
placeholder: el.placeholder || "",
|
|
873
|
+
nearbyButtonSelector
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
return results;
|
|
877
|
+
});
|
|
878
|
+
for (const input of standaloneInputs) {
|
|
879
|
+
if (!INJECTABLE_INPUT_TYPES.has(input.type.toLowerCase())) continue;
|
|
880
|
+
forms.push({
|
|
881
|
+
pageUrl,
|
|
882
|
+
formSelector: "(standalone)",
|
|
883
|
+
action: pageUrl,
|
|
884
|
+
method: "GET",
|
|
885
|
+
inputs: [
|
|
886
|
+
{
|
|
887
|
+
selector: input.selector,
|
|
888
|
+
type: input.type,
|
|
889
|
+
name: input.name,
|
|
890
|
+
injectable: true,
|
|
891
|
+
placeholder: input.placeholder || void 0
|
|
892
|
+
}
|
|
893
|
+
],
|
|
894
|
+
submitSelector: input.nearbyButtonSelector
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
return forms;
|
|
898
|
+
}
|
|
899
|
+
async function discoverLinks(page, origin, sameOrigin) {
|
|
900
|
+
const links = await page.evaluate(() => {
|
|
901
|
+
return Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((href) => href.startsWith("http"));
|
|
902
|
+
});
|
|
903
|
+
if (sameOrigin) {
|
|
904
|
+
return links.filter((link) => {
|
|
905
|
+
try {
|
|
906
|
+
return new URL(link).origin === origin;
|
|
907
|
+
} catch {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return links;
|
|
913
|
+
}
|
|
914
|
+
function buildSessions(forms) {
|
|
915
|
+
const targetForms = forms.filter((f) => f.inputs.some((i) => i.injectable));
|
|
916
|
+
return targetForms.map((form, idx) => buildSessionForForm(form, idx));
|
|
917
|
+
}
|
|
918
|
+
function buildSessionForForm(form, index) {
|
|
919
|
+
const steps = [];
|
|
920
|
+
let stepNum = 1;
|
|
921
|
+
steps.push({
|
|
922
|
+
id: `step-${stepNum++}`,
|
|
923
|
+
type: "browser.navigate",
|
|
924
|
+
url: form.pageUrl,
|
|
925
|
+
timestamp: Date.now()
|
|
926
|
+
});
|
|
927
|
+
const injectableInputs = form.inputs.filter((i) => i.injectable);
|
|
928
|
+
for (const input of injectableInputs) {
|
|
929
|
+
steps.push({
|
|
930
|
+
id: `step-${stepNum++}`,
|
|
931
|
+
type: "browser.input",
|
|
932
|
+
selector: input.selector,
|
|
933
|
+
value: "test",
|
|
934
|
+
injectable: true,
|
|
935
|
+
timestamp: Date.now() + stepNum * 100
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
if (form.submitSelector) {
|
|
939
|
+
steps.push({
|
|
940
|
+
id: `step-${stepNum++}`,
|
|
941
|
+
type: "browser.click",
|
|
942
|
+
selector: form.submitSelector,
|
|
943
|
+
timestamp: Date.now() + stepNum * 100
|
|
944
|
+
});
|
|
945
|
+
} else {
|
|
946
|
+
steps.push({
|
|
947
|
+
id: `step-${stepNum++}`,
|
|
948
|
+
type: "browser.keypress",
|
|
949
|
+
key: "Enter",
|
|
950
|
+
timestamp: Date.now() + stepNum * 100
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const inputNames = injectableInputs.map((i) => i.name || i.type).join(", ");
|
|
954
|
+
const pagePath = (() => {
|
|
955
|
+
try {
|
|
956
|
+
return new URL(form.pageUrl).pathname;
|
|
957
|
+
} catch {
|
|
958
|
+
return form.pageUrl;
|
|
959
|
+
}
|
|
960
|
+
})();
|
|
961
|
+
return {
|
|
962
|
+
name: `Crawl: ${pagePath} \u2014 form ${index + 1} (${inputNames})`,
|
|
963
|
+
driver: "browser",
|
|
964
|
+
driverConfig: {
|
|
965
|
+
startUrl: form.pageUrl,
|
|
966
|
+
browser: "chromium",
|
|
967
|
+
headless: true,
|
|
968
|
+
viewport: { width: 1280, height: 720 }
|
|
969
|
+
},
|
|
970
|
+
steps,
|
|
971
|
+
metadata: {
|
|
972
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
973
|
+
version: "0.3.0",
|
|
974
|
+
source: "crawler",
|
|
975
|
+
formAction: form.action,
|
|
976
|
+
formMethod: form.method
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function normalizeUrl(url) {
|
|
981
|
+
try {
|
|
982
|
+
const parsed = new URL(url);
|
|
983
|
+
parsed.hash = "";
|
|
984
|
+
if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) {
|
|
985
|
+
parsed.pathname = parsed.pathname.slice(0, -1);
|
|
986
|
+
}
|
|
987
|
+
return parsed.href;
|
|
988
|
+
} catch {
|
|
989
|
+
return url;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
658
993
|
// src/index.ts
|
|
659
994
|
var configSchema = import_zod.z.object({
|
|
660
995
|
/** Starting URL for recording */
|
|
@@ -724,6 +1059,18 @@ var recorderDriver = {
|
|
|
724
1059
|
async start(config, options) {
|
|
725
1060
|
const parsedConfig = configSchema.parse(config);
|
|
726
1061
|
return BrowserRecorder.start(parsedConfig, options);
|
|
1062
|
+
},
|
|
1063
|
+
async crawl(config, options) {
|
|
1064
|
+
const parsedConfig = configSchema.parse(config);
|
|
1065
|
+
return crawlAndBuildSessions(
|
|
1066
|
+
{
|
|
1067
|
+
startUrl: parsedConfig.startUrl ?? "",
|
|
1068
|
+
browser: parsedConfig.browser,
|
|
1069
|
+
headless: parsedConfig.headless,
|
|
1070
|
+
viewport: parsedConfig.viewport
|
|
1071
|
+
},
|
|
1072
|
+
options
|
|
1073
|
+
);
|
|
727
1074
|
}
|
|
728
1075
|
};
|
|
729
1076
|
var runnerDriver = {
|
|
@@ -750,6 +1097,7 @@ var index_default = browserDriver;
|
|
|
750
1097
|
BrowserStepSchema,
|
|
751
1098
|
checkBrowsers,
|
|
752
1099
|
configSchema,
|
|
1100
|
+
crawlAndBuildSessions,
|
|
753
1101
|
installBrowsers,
|
|
754
1102
|
launchBrowser
|
|
755
1103
|
});
|