@vulcn/driver-browser 0.3.0 → 0.4.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/dist/index.cjs CHANGED
@@ -24,12 +24,14 @@ __export(index_exports, {
24
24
  BrowserRecorder: () => BrowserRecorder,
25
25
  BrowserRunner: () => BrowserRunner,
26
26
  BrowserStepSchema: () => BrowserStepSchema,
27
+ buildCapturedRequests: () => buildCapturedRequests,
27
28
  checkBrowsers: () => checkBrowsers,
28
29
  checkSessionAlive: () => checkSessionAlive,
29
30
  configSchema: () => configSchema,
30
31
  crawlAndBuildSessions: () => crawlAndBuildSessions,
31
32
  default: () => index_default,
32
33
  detectLoginForm: () => detectLoginForm,
34
+ httpScan: () => httpScan,
33
35
  installBrowsers: () => installBrowsers,
34
36
  launchBrowser: () => launchBrowser,
35
37
  performLogin: () => performLogin
@@ -814,6 +816,248 @@ function interleavePayloads(payloads) {
814
816
  return result;
815
817
  }
816
818
 
819
+ // src/http-scanner.ts
820
+ async function httpScan(requests, payloads, options = {}) {
821
+ const timeout = options.timeout ?? 1e4;
822
+ const concurrency = options.concurrency ?? 10;
823
+ const start = Date.now();
824
+ const findings = [];
825
+ const reflectedRequests = [];
826
+ let requestsSent = 0;
827
+ const tasks = [];
828
+ for (const request of requests) {
829
+ if (!request.injectableField) continue;
830
+ for (const payloadSet of payloads) {
831
+ for (const value of payloadSet.payloads) {
832
+ tasks.push({ request, payloadSet, value });
833
+ }
834
+ }
835
+ }
836
+ const totalTasks = tasks.length;
837
+ if (totalTasks === 0) {
838
+ return { requestsSent: 0, duration: 0, findings, reflectedRequests };
839
+ }
840
+ for (let i = 0; i < tasks.length; i += concurrency) {
841
+ const batch = tasks.slice(i, i + concurrency);
842
+ const results = await Promise.allSettled(
843
+ batch.map(async ({ request, payloadSet, value }) => {
844
+ try {
845
+ const body = await sendPayload(request, value, {
846
+ timeout,
847
+ cookies: options.cookies,
848
+ headers: options.headers
849
+ });
850
+ requestsSent++;
851
+ const finding = checkHttpReflection(body, request, payloadSet, value);
852
+ if (finding) {
853
+ findings.push(finding);
854
+ reflectedRequests.push({
855
+ request,
856
+ payload: value,
857
+ category: payloadSet.category
858
+ });
859
+ }
860
+ } catch {
861
+ requestsSent++;
862
+ }
863
+ })
864
+ );
865
+ const completed = Math.min(i + batch.length, totalTasks);
866
+ options.onProgress?.(completed, totalTasks);
867
+ for (const result of results) {
868
+ if (result.status === "rejected") {
869
+ }
870
+ }
871
+ }
872
+ return {
873
+ requestsSent,
874
+ duration: Date.now() - start,
875
+ findings,
876
+ reflectedRequests
877
+ };
878
+ }
879
+ async function sendPayload(request, payload, options) {
880
+ const { method, url, headers, body, contentType, injectableField } = request;
881
+ const reqHeaders = {
882
+ ...headers,
883
+ ...options.headers ?? {}
884
+ };
885
+ if (options.cookies) {
886
+ reqHeaders["Cookie"] = options.cookies;
887
+ }
888
+ delete reqHeaders["content-length"];
889
+ delete reqHeaders["Content-Length"];
890
+ let requestUrl = url;
891
+ let requestBody;
892
+ if (method.toUpperCase() === "GET") {
893
+ requestUrl = injectIntoUrl(url, injectableField, payload);
894
+ } else {
895
+ requestBody = injectIntoBody(body, contentType, injectableField, payload);
896
+ if (contentType) {
897
+ reqHeaders["Content-Type"] = contentType;
898
+ } else {
899
+ reqHeaders["Content-Type"] = "application/x-www-form-urlencoded";
900
+ }
901
+ }
902
+ const controller = new AbortController();
903
+ const timer = setTimeout(() => controller.abort(), options.timeout);
904
+ try {
905
+ const response = await fetch(requestUrl, {
906
+ method: method.toUpperCase(),
907
+ headers: reqHeaders,
908
+ body: requestBody,
909
+ signal: controller.signal,
910
+ redirect: "follow"
911
+ });
912
+ return await response.text();
913
+ } finally {
914
+ clearTimeout(timer);
915
+ }
916
+ }
917
+ function injectIntoUrl(url, field, payload) {
918
+ try {
919
+ const parsed = new URL(url);
920
+ parsed.searchParams.set(field, payload);
921
+ return parsed.toString();
922
+ } catch {
923
+ const separator = url.includes("?") ? "&" : "?";
924
+ return `${url}${separator}${encodeURIComponent(field)}=${encodeURIComponent(payload)}`;
925
+ }
926
+ }
927
+ function injectIntoBody(body, contentType, field, payload) {
928
+ if (!body) {
929
+ return `${encodeURIComponent(field)}=${encodeURIComponent(payload)}`;
930
+ }
931
+ const ct = (contentType ?? "").toLowerCase();
932
+ if (ct.includes("application/json")) {
933
+ return injectIntoJson(body, field, payload);
934
+ }
935
+ if (ct.includes("multipart/form-data")) {
936
+ return injectIntoMultipart(body, field, payload);
937
+ }
938
+ return injectIntoFormUrlEncoded(body, field, payload);
939
+ }
940
+ function injectIntoFormUrlEncoded(body, field, payload) {
941
+ const params = new URLSearchParams(body);
942
+ params.set(field, payload);
943
+ return params.toString();
944
+ }
945
+ function injectIntoJson(body, field, payload) {
946
+ try {
947
+ const parsed = JSON.parse(body);
948
+ if (typeof parsed === "object" && parsed !== null) {
949
+ parsed[field] = payload;
950
+ return JSON.stringify(parsed);
951
+ }
952
+ } catch {
953
+ }
954
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
955
+ const regex = new RegExp(`("${escaped}"\\s*:\\s*)"[^"]*"`, "g");
956
+ const replaced = body.replace(regex, `$1"${payload}"`);
957
+ if (replaced !== body) return replaced;
958
+ return body;
959
+ }
960
+ function injectIntoMultipart(body, field, payload) {
961
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
962
+ const regex = new RegExp(
963
+ `(Content-Disposition:\\s*form-data;\\s*name="${escaped}"\\r?\\n\\r?\\n)[^\\r\\n-]*`,
964
+ "i"
965
+ );
966
+ return body.replace(regex, `$1${payload}`);
967
+ }
968
+ function checkHttpReflection(responseBody, request, payloadSet, payloadValue) {
969
+ for (const pattern of payloadSet.detectPatterns) {
970
+ if (pattern.test(responseBody)) {
971
+ return {
972
+ type: payloadSet.category,
973
+ severity: getSeverity2(payloadSet.category),
974
+ title: `${payloadSet.category.toUpperCase()} reflection detected (HTTP)`,
975
+ description: `Payload pattern was reflected in HTTP response body. Needs browser confirmation for execution proof.`,
976
+ stepId: `http-${request.sessionName}`,
977
+ payload: payloadValue,
978
+ url: request.url,
979
+ evidence: responseBody.match(pattern)?.[0]?.slice(0, 200),
980
+ metadata: {
981
+ detectionMethod: "tier1-http",
982
+ needsBrowserConfirmation: true,
983
+ requestMethod: request.method,
984
+ injectableField: request.injectableField
985
+ }
986
+ };
987
+ }
988
+ }
989
+ if (responseBody.includes(payloadValue)) {
990
+ return {
991
+ type: payloadSet.category,
992
+ severity: "medium",
993
+ title: `Potential ${payloadSet.category.toUpperCase()} \u2014 payload reflected in HTTP response`,
994
+ description: `Payload was reflected in HTTP response without encoding. Escalate to browser for execution proof.`,
995
+ stepId: `http-${request.sessionName}`,
996
+ payload: payloadValue,
997
+ url: request.url,
998
+ metadata: {
999
+ detectionMethod: "tier1-http",
1000
+ needsBrowserConfirmation: true,
1001
+ requestMethod: request.method,
1002
+ injectableField: request.injectableField
1003
+ }
1004
+ };
1005
+ }
1006
+ return void 0;
1007
+ }
1008
+ function getSeverity2(category) {
1009
+ switch (category) {
1010
+ case "sqli":
1011
+ case "command-injection":
1012
+ case "xxe":
1013
+ return "critical";
1014
+ case "xss":
1015
+ case "ssrf":
1016
+ case "path-traversal":
1017
+ return "high";
1018
+ case "open-redirect":
1019
+ return "medium";
1020
+ default:
1021
+ return "medium";
1022
+ }
1023
+ }
1024
+ function buildCapturedRequests(forms) {
1025
+ const requests = [];
1026
+ for (const form of forms) {
1027
+ const injectableInputs = form.inputs.filter((i) => i.injectable);
1028
+ if (injectableInputs.length === 0) continue;
1029
+ let actionUrl;
1030
+ try {
1031
+ actionUrl = new URL(form.action, form.pageUrl).toString();
1032
+ } catch {
1033
+ actionUrl = form.pageUrl;
1034
+ }
1035
+ const method = (form.method || "GET").toUpperCase();
1036
+ for (const input of injectableInputs) {
1037
+ const formParams = new URLSearchParams();
1038
+ for (const inp of form.inputs) {
1039
+ formParams.set(inp.name || inp.type, inp.injectable ? "test" : "");
1040
+ }
1041
+ const request = {
1042
+ method,
1043
+ url: method === "GET" ? actionUrl : actionUrl,
1044
+ headers: {
1045
+ "User-Agent": "Vulcn/1.0 (Security Scanner)",
1046
+ Accept: "text/html,application/xhtml+xml,*/*"
1047
+ },
1048
+ ...method !== "GET" ? {
1049
+ body: formParams.toString(),
1050
+ contentType: "application/x-www-form-urlencoded"
1051
+ } : {},
1052
+ injectableField: input.name || input.type,
1053
+ sessionName: form.sessionName
1054
+ };
1055
+ requests.push(request);
1056
+ }
1057
+ }
1058
+ return requests;
1059
+ }
1060
+
817
1061
  // src/crawler.ts
818
1062
  var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
819
1063
  "text",
@@ -900,7 +1144,20 @@ async function crawlAndBuildSessions(config, options = {}) {
900
1144
  console.log(
901
1145
  `[crawler] Complete: ${visited.size} page(s), ${allForms.length} form(s)`
902
1146
  );
903
- return buildSessions(allForms);
1147
+ const sessions = buildSessions(allForms);
1148
+ const capturedRequests = buildCapturedRequests(
1149
+ allForms.filter((f) => f.inputs.some((i) => i.injectable)).map((form, idx) => ({
1150
+ pageUrl: form.pageUrl,
1151
+ action: form.action,
1152
+ method: form.method,
1153
+ inputs: form.inputs,
1154
+ sessionName: sessions[idx]?.name ?? `form-${idx + 1}`
1155
+ }))
1156
+ );
1157
+ console.log(
1158
+ `[crawler] Generated ${sessions.length} session(s), ${capturedRequests.length} HTTP request(s) for Tier 1`
1159
+ );
1160
+ return { sessions, capturedRequests };
904
1161
  }
905
1162
  async function discoverForms(page, pageUrl) {
906
1163
  const forms = [];
@@ -1413,7 +1670,7 @@ var recorderDriver = {
1413
1670
  },
1414
1671
  async crawl(config, options) {
1415
1672
  const parsedConfig = configSchema.parse(config);
1416
- return crawlAndBuildSessions(
1673
+ const result = await crawlAndBuildSessions(
1417
1674
  {
1418
1675
  startUrl: parsedConfig.startUrl ?? "",
1419
1676
  browser: parsedConfig.browser,
@@ -1422,6 +1679,7 @@ var recorderDriver = {
1422
1679
  },
1423
1680
  options
1424
1681
  );
1682
+ return result.sessions;
1425
1683
  }
1426
1684
  };
1427
1685
  var runnerDriver = {
@@ -1446,11 +1704,13 @@ var index_default = browserDriver;
1446
1704
  BrowserRecorder,
1447
1705
  BrowserRunner,
1448
1706
  BrowserStepSchema,
1707
+ buildCapturedRequests,
1449
1708
  checkBrowsers,
1450
1709
  checkSessionAlive,
1451
1710
  configSchema,
1452
1711
  crawlAndBuildSessions,
1453
1712
  detectLoginForm,
1713
+ httpScan,
1454
1714
  installBrowsers,
1455
1715
  launchBrowser,
1456
1716
  performLogin