@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/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
- if (message.includes("vulcn") || message === currentPayloadInfo.payloadValue) {
443
+ const dialogType = dialog.type();
444
+ if (dialogType !== "beforeunload") {
443
445
  eventFindings.push({
444
446
  type: "xss",
445
447
  severity: "high",
446
- title: "XSS Confirmed - Dialog Triggered",
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 message: ${message}`,
453
+ evidence: `Dialog type: ${dialogType}, Message: ${message}`,
452
454
  metadata: {
453
- dialogType: dialog.type(),
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
- await page.click(browserStep.selector, { timeout: 5e3 });
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
  });