@patchstack/connect 0.3.8 → 0.3.10
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/AGENT-INSTALL.md +9 -11
- package/README.md +32 -8
- package/dist/cli.js +169 -34
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +74 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +68 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +311 -18
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +26 -3
- package/dist/protect.js +311 -18
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
package/dist/protect.js
CHANGED
|
@@ -117,10 +117,50 @@ var RequestResolver = class {
|
|
|
117
117
|
return this.#resolveServer(key);
|
|
118
118
|
case "files":
|
|
119
119
|
return this.#resolveFiles(key);
|
|
120
|
+
case "response":
|
|
121
|
+
return this.#resolveResponse(key);
|
|
122
|
+
case "egress":
|
|
123
|
+
return this.#resolveEgress(key);
|
|
120
124
|
default:
|
|
121
125
|
return [];
|
|
122
126
|
}
|
|
123
127
|
}
|
|
128
|
+
// Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
|
|
129
|
+
// what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
|
|
130
|
+
#resolveResponse(key) {
|
|
131
|
+
const resp = this.#req._response;
|
|
132
|
+
if (!resp) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
if (key === "status") {
|
|
136
|
+
return resp.status !== void 0 ? [String(resp.status)] : [];
|
|
137
|
+
}
|
|
138
|
+
if (key === "body") {
|
|
139
|
+
return resp.body != null && resp.body !== "" ? [resp.body] : [];
|
|
140
|
+
}
|
|
141
|
+
if (key === "headers") {
|
|
142
|
+
const headers = resp.headers ?? {};
|
|
143
|
+
return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
|
|
144
|
+
}
|
|
145
|
+
if (key.startsWith("header.")) {
|
|
146
|
+
const name = key.slice("header.".length).toLowerCase();
|
|
147
|
+
const value = (resp.headers ?? {})[name];
|
|
148
|
+
return value !== void 0 ? [value] : [];
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
// Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an
|
|
153
|
+
// OUTBOUND request the app is about to make — SSRF at the egress boundary.
|
|
154
|
+
#resolveEgress(key) {
|
|
155
|
+
const eg = this.#req._egress;
|
|
156
|
+
if (!eg) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
if (key === "url") return eg.url ? [eg.url] : [];
|
|
160
|
+
if (key === "host") return eg.host ? [eg.host] : [];
|
|
161
|
+
if (key === "method") return eg.method ? [eg.method] : [];
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
124
164
|
applyMutations(mutations, value) {
|
|
125
165
|
if (!mutations || !Array.isArray(mutations)) {
|
|
126
166
|
return value;
|
|
@@ -613,6 +653,24 @@ function warnUnsupportedMatchType(type) {
|
|
|
613
653
|
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
614
654
|
);
|
|
615
655
|
}
|
|
656
|
+
function isInternalHost(hostname) {
|
|
657
|
+
if (!hostname) return false;
|
|
658
|
+
const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
659
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
|
660
|
+
if (host === "metadata.google.internal") return true;
|
|
661
|
+
if (host === "::1" || host === "::") return true;
|
|
662
|
+
if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
|
|
663
|
+
const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
664
|
+
if (v4) {
|
|
665
|
+
const a = Number(v4[1]);
|
|
666
|
+
const b = Number(v4[2]);
|
|
667
|
+
if (a === 127 || a === 10 || a === 0) return true;
|
|
668
|
+
if (a === 169 && b === 254) return true;
|
|
669
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
670
|
+
if (a === 192 && b === 168) return true;
|
|
671
|
+
}
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
616
674
|
function matchValue(type, value, matchVal, matchObj) {
|
|
617
675
|
if (value === null || value === void 0) {
|
|
618
676
|
if (type === "isset") {
|
|
@@ -679,6 +737,8 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
679
737
|
return false;
|
|
680
738
|
}
|
|
681
739
|
}
|
|
740
|
+
case "internal_host":
|
|
741
|
+
return isInternalHost(strValue);
|
|
682
742
|
case "quotes":
|
|
683
743
|
// engine-php exposes `inline_js_xss` as an alias of `quotes`.
|
|
684
744
|
case "inline_js_xss":
|
|
@@ -790,7 +850,8 @@ var RuleEngine = class {
|
|
|
790
850
|
if (parameter === "rules" && Array.isArray(condition.rules)) {
|
|
791
851
|
return this.#evaluateRule(condition.rules, resolver);
|
|
792
852
|
}
|
|
793
|
-
const
|
|
853
|
+
const params = Array.isArray(parameter) ? parameter : [parameter];
|
|
854
|
+
const values = params.flatMap((p) => resolver.resolve(p));
|
|
794
855
|
if (values.length === 0) {
|
|
795
856
|
return false;
|
|
796
857
|
}
|
|
@@ -972,6 +1033,98 @@ function parseCookies2(header) {
|
|
|
972
1033
|
return cookies;
|
|
973
1034
|
}
|
|
974
1035
|
|
|
1036
|
+
// src/protect/egress.js
|
|
1037
|
+
function installEgressGuard({ shouldBlock, onBlock } = {}) {
|
|
1038
|
+
const original = globalThis.fetch;
|
|
1039
|
+
if (typeof original !== "function" || original.__patchstackGuarded || typeof shouldBlock !== "function") {
|
|
1040
|
+
return () => {
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const guarded = async (input, init) => {
|
|
1044
|
+
const url = typeof input === "string" ? input : input && input.url || String(input);
|
|
1045
|
+
let host = null;
|
|
1046
|
+
try {
|
|
1047
|
+
host = new URL(url).hostname;
|
|
1048
|
+
} catch {
|
|
1049
|
+
host = null;
|
|
1050
|
+
}
|
|
1051
|
+
const method = init && init.method || input && input.method || "GET";
|
|
1052
|
+
if (shouldBlock(url, host, method)) {
|
|
1053
|
+
onBlock?.({ url, host, method });
|
|
1054
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
|
|
1055
|
+
}
|
|
1056
|
+
return original(input, init);
|
|
1057
|
+
};
|
|
1058
|
+
guarded.__patchstackGuarded = true;
|
|
1059
|
+
globalThis.fetch = guarded;
|
|
1060
|
+
return () => {
|
|
1061
|
+
if (globalThis.fetch === guarded) {
|
|
1062
|
+
globalThis.fetch = original;
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// src/protect/defaults.js
|
|
1068
|
+
var DEFAULT_RESPONSE_RULES = [
|
|
1069
|
+
{
|
|
1070
|
+
id: "resp-private-key",
|
|
1071
|
+
title: "Private key in response body",
|
|
1072
|
+
phase: "response",
|
|
1073
|
+
category: "secret-exposure",
|
|
1074
|
+
action: "redact",
|
|
1075
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
id: "resp-aws-access-key",
|
|
1079
|
+
title: "AWS access key id in response body",
|
|
1080
|
+
phase: "response",
|
|
1081
|
+
category: "secret-exposure",
|
|
1082
|
+
action: "redact",
|
|
1083
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
1084
|
+
},
|
|
1085
|
+
{
|
|
1086
|
+
id: "resp-gcp-api-key",
|
|
1087
|
+
title: "Google API key in response body",
|
|
1088
|
+
phase: "response",
|
|
1089
|
+
category: "secret-exposure",
|
|
1090
|
+
action: "redact",
|
|
1091
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
id: "resp-jwt",
|
|
1095
|
+
title: "JWT in response body",
|
|
1096
|
+
phase: "response",
|
|
1097
|
+
category: "secret-exposure",
|
|
1098
|
+
action: "redact",
|
|
1099
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
id: "resp-db-connection-string",
|
|
1103
|
+
title: "Database connection string with credentials in response body",
|
|
1104
|
+
phase: "response",
|
|
1105
|
+
category: "secret-exposure",
|
|
1106
|
+
action: "redact",
|
|
1107
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
id: "resp-stack-trace",
|
|
1111
|
+
title: "Node stack trace leaking in response body",
|
|
1112
|
+
phase: "response",
|
|
1113
|
+
category: "info-exposure",
|
|
1114
|
+
action: "redact",
|
|
1115
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
1116
|
+
}
|
|
1117
|
+
];
|
|
1118
|
+
var DEFAULT_EGRESS_RULES = [
|
|
1119
|
+
{
|
|
1120
|
+
id: "egress-internal-address",
|
|
1121
|
+
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
1122
|
+
phase: "egress",
|
|
1123
|
+
category: "ssrf",
|
|
1124
|
+
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
1125
|
+
}
|
|
1126
|
+
];
|
|
1127
|
+
|
|
975
1128
|
// src/protect/runtime.js
|
|
976
1129
|
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
977
1130
|
import { join } from "path";
|
|
@@ -1050,16 +1203,75 @@ async function createProtection(options = {}) {
|
|
|
1050
1203
|
const onError = options.onError;
|
|
1051
1204
|
const onDetect = options.onDetect ?? defaultOnDetect;
|
|
1052
1205
|
const bundle = await resolveRules(options);
|
|
1053
|
-
const
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1206
|
+
const incoming = bundle.firewall ?? [];
|
|
1207
|
+
const requestRules = byPhase(incoming, "request");
|
|
1208
|
+
const responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
|
|
1209
|
+
const egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
|
|
1210
|
+
const engine = new RuleEngine({
|
|
1211
|
+
firewall: requestRules,
|
|
1212
|
+
whitelists: bundle.whitelists,
|
|
1213
|
+
whitelist_keys: bundle.whitelist_keys,
|
|
1214
|
+
onError
|
|
1215
|
+
});
|
|
1216
|
+
const responseRuleSet = responseRules.map((rule) => ({
|
|
1217
|
+
rule,
|
|
1218
|
+
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
1219
|
+
redactors: rule.action === "redact" ? extractRedactors(rule) : null
|
|
1220
|
+
}));
|
|
1221
|
+
const egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
1222
|
+
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
1223
|
+
const decide = (phase, result, block, allow2) => {
|
|
1224
|
+
if (!result || !result.blocked) return allow2();
|
|
1225
|
+
onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
|
|
1226
|
+
return mode === "block" ? block() : allow2();
|
|
1227
|
+
};
|
|
1228
|
+
const screenFetchResponse = async (response) => {
|
|
1229
|
+
const text = await readTextResponse(response);
|
|
1230
|
+
if (text == null) return response;
|
|
1231
|
+
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
1232
|
+
let blockRule = null;
|
|
1233
|
+
const redactions = [];
|
|
1234
|
+
for (const { rule, engine: re, redactors } of responseRuleSet) {
|
|
1235
|
+
let result;
|
|
1236
|
+
try {
|
|
1237
|
+
result = re.evaluate({ _response: { ...meta, body: text } });
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
onError?.(err);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
if (!result.blocked) continue;
|
|
1243
|
+
onDetect({ phase: "response", mode, category: rule.category, rule, message: result.message });
|
|
1244
|
+
if (mode !== "block") continue;
|
|
1245
|
+
if (redactors && redactors.length) redactions.push({ rule, redactors });
|
|
1246
|
+
else if (!blockRule) blockRule = rule;
|
|
1247
|
+
}
|
|
1248
|
+
if (mode !== "block") return response;
|
|
1249
|
+
if (blockRule) return leakResponse();
|
|
1250
|
+
if (redactions.length) {
|
|
1251
|
+
let body = text;
|
|
1252
|
+
for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
|
|
1253
|
+
return rebuildResponse(response, body);
|
|
1254
|
+
}
|
|
1255
|
+
return response;
|
|
1256
|
+
};
|
|
1257
|
+
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
1258
|
+
const egressShouldBlock = (url, host, method) => {
|
|
1259
|
+
if (host && allow.has(host.toLowerCase())) return false;
|
|
1260
|
+
let result;
|
|
1261
|
+
try {
|
|
1262
|
+
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
onError?.(err);
|
|
1265
|
+
return false;
|
|
1266
|
+
}
|
|
1267
|
+
if (!result.blocked) return false;
|
|
1268
|
+
onDetect({ phase: "egress", mode, category: result.rule?.category, rule: result.rule, message: result.message });
|
|
1269
|
+
return mode === "block";
|
|
1058
1270
|
};
|
|
1059
1271
|
const protection = {
|
|
1060
1272
|
mode,
|
|
1061
|
-
rules:
|
|
1062
|
-
// (request) => Response | null (null = allow, caller proceeds)
|
|
1273
|
+
rules: { request: requestRules, response: responseRules, egress: egressRules },
|
|
1274
|
+
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
1063
1275
|
fetchGuard() {
|
|
1064
1276
|
return async (request) => {
|
|
1065
1277
|
let result;
|
|
@@ -1069,15 +1281,20 @@ async function createProtection(options = {}) {
|
|
|
1069
1281
|
onError?.(err);
|
|
1070
1282
|
return null;
|
|
1071
1283
|
}
|
|
1072
|
-
return decide(result, () => blockResponse(result), () => null);
|
|
1284
|
+
return decide("request", result, () => blockResponse(result), () => null);
|
|
1073
1285
|
};
|
|
1074
1286
|
},
|
|
1075
|
-
// Wrap a fetch handler:
|
|
1287
|
+
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
1076
1288
|
fetch(handler) {
|
|
1077
1289
|
const guard = protection.fetchGuard();
|
|
1078
|
-
return async (request, ...rest) =>
|
|
1290
|
+
return async (request, ...rest) => {
|
|
1291
|
+
const blocked = await guard(request);
|
|
1292
|
+
if (blocked) return blocked;
|
|
1293
|
+
const response = await handler(request, ...rest);
|
|
1294
|
+
return screenFetchResponse(response);
|
|
1295
|
+
};
|
|
1079
1296
|
},
|
|
1080
|
-
// Express middleware (expects express-parsed req.query/req.body).
|
|
1297
|
+
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
1081
1298
|
express() {
|
|
1082
1299
|
return (req, res, next) => {
|
|
1083
1300
|
let result;
|
|
@@ -1087,10 +1304,10 @@ async function createProtection(options = {}) {
|
|
|
1087
1304
|
onError?.(err);
|
|
1088
1305
|
return next();
|
|
1089
1306
|
}
|
|
1090
|
-
decide(result, () => res.status(403).json(blockBody(result)), () => next());
|
|
1307
|
+
decide("request", result, () => res.status(403).json(blockBody(result)), () => next());
|
|
1091
1308
|
};
|
|
1092
1309
|
},
|
|
1093
|
-
// Node / Connect middleware — buffers the body itself (
|
|
1310
|
+
// Node / Connect middleware — buffers the body itself (request phase).
|
|
1094
1311
|
node(nodeOptions = {}) {
|
|
1095
1312
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
1096
1313
|
return (req, res, next) => {
|
|
@@ -1111,28 +1328,104 @@ async function createProtection(options = {}) {
|
|
|
1111
1328
|
});
|
|
1112
1329
|
req.on("end", () => {
|
|
1113
1330
|
const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
|
|
1331
|
+
let shaped;
|
|
1114
1332
|
let result;
|
|
1115
1333
|
try {
|
|
1116
|
-
|
|
1334
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
1335
|
+
result = engine.evaluate(shaped);
|
|
1117
1336
|
} catch (err) {
|
|
1118
1337
|
onError?.(err);
|
|
1119
1338
|
return next();
|
|
1120
1339
|
}
|
|
1121
1340
|
decide(
|
|
1341
|
+
"request",
|
|
1122
1342
|
result,
|
|
1123
1343
|
() => {
|
|
1124
1344
|
res.statusCode = 403;
|
|
1125
1345
|
res.setHeader("content-type", "application/json");
|
|
1126
1346
|
res.end(JSON.stringify(blockBody(result)));
|
|
1127
1347
|
},
|
|
1128
|
-
() =>
|
|
1348
|
+
() => {
|
|
1349
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
1350
|
+
next();
|
|
1351
|
+
}
|
|
1129
1352
|
);
|
|
1130
1353
|
});
|
|
1131
1354
|
};
|
|
1132
1355
|
}
|
|
1133
1356
|
};
|
|
1357
|
+
if (options.egress) {
|
|
1358
|
+
protection.uninstallEgress = installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
|
|
1359
|
+
}
|
|
1134
1360
|
return protection;
|
|
1135
1361
|
}
|
|
1362
|
+
function byPhase(rules, phase) {
|
|
1363
|
+
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
1364
|
+
}
|
|
1365
|
+
async function readTextResponse(response) {
|
|
1366
|
+
if (!response || typeof response.clone !== "function") return null;
|
|
1367
|
+
const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
|
|
1368
|
+
const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
|
|
1369
|
+
if (!isText) return null;
|
|
1370
|
+
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
1371
|
+
if (len && len > 512 * 1024) return null;
|
|
1372
|
+
try {
|
|
1373
|
+
const text = await response.clone().text();
|
|
1374
|
+
return text.length > 512 * 1024 ? null : text;
|
|
1375
|
+
} catch {
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function headerObject(headers) {
|
|
1380
|
+
const out = {};
|
|
1381
|
+
headers?.forEach?.((v, k) => {
|
|
1382
|
+
out[k.toLowerCase()] = v;
|
|
1383
|
+
});
|
|
1384
|
+
return out;
|
|
1385
|
+
}
|
|
1386
|
+
function extractRedactors(rule) {
|
|
1387
|
+
const out = [];
|
|
1388
|
+
const walk = (conds) => {
|
|
1389
|
+
for (const c of conds ?? []) {
|
|
1390
|
+
if (Array.isArray(c.rules)) walk(c.rules);
|
|
1391
|
+
const m = c.match;
|
|
1392
|
+
if (!m) continue;
|
|
1393
|
+
if (m.type === "regex" && typeof m.value === "string") {
|
|
1394
|
+
const parsed = m.value.match(/^\/(.+)\/([a-z]*)$/is);
|
|
1395
|
+
if (parsed) {
|
|
1396
|
+
const flags = parsed[2].includes("g") ? parsed[2] : parsed[2] + "g";
|
|
1397
|
+
try {
|
|
1398
|
+
out.push({ re: new RegExp(parsed[1], flags) });
|
|
1399
|
+
} catch {
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
1403
|
+
out.push({ literal: String(m.value) });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
walk(rule.rule_v2);
|
|
1408
|
+
return out;
|
|
1409
|
+
}
|
|
1410
|
+
function applyRedactors(body, redactors, mask) {
|
|
1411
|
+
let out = body;
|
|
1412
|
+
for (const r of redactors) {
|
|
1413
|
+
if (r.re) out = out.replace(r.re, mask);
|
|
1414
|
+
else if (r.literal) out = out.split(r.literal).join(mask);
|
|
1415
|
+
}
|
|
1416
|
+
return out;
|
|
1417
|
+
}
|
|
1418
|
+
function rebuildResponse(response, body) {
|
|
1419
|
+
const headers = new Headers(response.headers);
|
|
1420
|
+
headers.delete("content-length");
|
|
1421
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers });
|
|
1422
|
+
}
|
|
1423
|
+
function leakResponse() {
|
|
1424
|
+
return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
|
|
1425
|
+
status: 500,
|
|
1426
|
+
headers: { "content-type": "application/json" }
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1136
1429
|
async function resolveRules(options) {
|
|
1137
1430
|
if (options.rules) {
|
|
1138
1431
|
return normalizeBundle(options.rules);
|
|
@@ -1197,9 +1490,9 @@ function blockResponse(result) {
|
|
|
1197
1490
|
headers: { "content-type": "application/json" }
|
|
1198
1491
|
});
|
|
1199
1492
|
}
|
|
1200
|
-
function defaultOnDetect({ mode, rule, message }) {
|
|
1493
|
+
function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
1201
1494
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
1202
|
-
console.warn(`[patchstack] ${tag} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
1495
|
+
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
1203
1496
|
}
|
|
1204
1497
|
export {
|
|
1205
1498
|
GUARD_PATH,
|