@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 +262 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -3
- package/dist/index.d.ts +92 -3
- package/dist/index.js +260 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|