@patchstack/connect 0.3.11 → 0.3.13
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/README.md +1 -1
- package/dist/cli.js +26 -4
- package/dist/cli.js.map +1 -1
- package/dist/protect/templates/guard.ts +36 -4
- package/dist/protect/templates/rules.json +34 -7
- package/dist/protect.cjs +82 -2
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +4 -0
- package/dist/protect.js +81 -2
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// through handleGuardRequest (registered as request middleware in src/start.ts).
|
|
6
6
|
// - browser → TanStack server function → Supabase: inspectServerFn checks the server-fn args
|
|
7
7
|
// (registered as function middleware in src/start.ts) before anything is written.
|
|
8
|
-
// Either way Patchstack sees the traffic and runs the same policy.
|
|
8
|
+
// Either way Patchstack sees the traffic and runs the same policy. It also screens the app's own
|
|
9
|
+
// OUTBOUND calls for SSRF (egress: true wraps the global fetch) — blocking requests to internal /
|
|
10
|
+
// cloud-metadata addresses while allowing the app's own Supabase project.
|
|
9
11
|
//
|
|
10
12
|
// Always-on by default (blocks). Rules come from the Patchstack API per-site (cached); the
|
|
11
13
|
// bundled rules.json is only a fallback for before a token is configured. The engine ships
|
|
@@ -18,6 +20,9 @@ import {
|
|
|
18
20
|
} from "@patchstack/connect/protect";
|
|
19
21
|
import fallbackRules from "./rules.json";
|
|
20
22
|
|
|
23
|
+
// Baked by `patchstack-connect protect` from .patchstackrc.json (empty if the app isn't scanned yet).
|
|
24
|
+
const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
|
|
25
|
+
|
|
21
26
|
export { GUARD_PATH };
|
|
22
27
|
|
|
23
28
|
// One shared protection policy for both guards (rules load once).
|
|
@@ -27,10 +32,22 @@ async function getProtection() {
|
|
|
27
32
|
// Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
|
|
28
33
|
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
|
|
29
34
|
const token = process.env.PATCHSTACK_WAF_TOKEN;
|
|
35
|
+
const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
|
|
36
|
+
// Egress SSRF screening: block the app's outbound calls to internal / metadata addresses,
|
|
37
|
+
// but never its own Supabase project.
|
|
38
|
+
let allowHosts: string[] = [];
|
|
39
|
+
try {
|
|
40
|
+
if (process.env.SUPABASE_URL) allowHosts = [new URL(process.env.SUPABASE_URL).host];
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore a malformed SUPABASE_URL — just don't add an allow entry */
|
|
43
|
+
}
|
|
44
|
+
const common = { mode, egress: true, allowHosts };
|
|
30
45
|
_protection = await createProtection(
|
|
31
|
-
|
|
32
|
-
? {
|
|
33
|
-
:
|
|
46
|
+
siteUuid
|
|
47
|
+
? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } // live per-site rules; bundled = offline fallback
|
|
48
|
+
: token
|
|
49
|
+
? { ...common, token, cacheDir: ".patchstack" } // live per-site WAF rules from the Patchstack API (cached)
|
|
50
|
+
: { ...common, rules: fallbackRules as never }, // demo fallback until a site UUID / token is set
|
|
34
51
|
);
|
|
35
52
|
}
|
|
36
53
|
return _protection;
|
|
@@ -57,3 +74,18 @@ export async function inspectServerFn(data: unknown): Promise<{ rule?: string; m
|
|
|
57
74
|
}
|
|
58
75
|
return _inspect(data);
|
|
59
76
|
}
|
|
77
|
+
|
|
78
|
+
// Response phase: redact leaked secrets / PII (private keys, cloud keys, JWTs, DB URLs, …) from an
|
|
79
|
+
// outgoing response before it leaves the server. Applied to the SSR / non-tunnel response in
|
|
80
|
+
// src/start.ts (the browser→Supabase tunnel screens its own forwarded response). Only acts on a
|
|
81
|
+
// web Response (text/JSON/HTML) — anything else, or any error, passes through untouched (fail-open,
|
|
82
|
+
// never breaks a response).
|
|
83
|
+
export async function screenResponse(response: unknown): Promise<unknown> {
|
|
84
|
+
try {
|
|
85
|
+
if (!(response instanceof Response)) return response;
|
|
86
|
+
const protection = await getProtection();
|
|
87
|
+
return protection.screenResponse ? await protection.screenResponse(response) : response;
|
|
88
|
+
} catch {
|
|
89
|
+
return response; // fail open
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "
|
|
2
|
+
"_comment": "Starter fallback rules — used ONLY when PATCHSTACK_WAF_TOKEN is unset. The guard blocks by default, so these are deliberately HIGH-PRECISION signatures (structural markers legitimate traffic doesn't carry) to avoid false positives before a token is configured. Once a token is set the guard fetches the per-site, version-scoped corpus from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk) and this file is no longer used. For a broad, many-class DEMONSTRATION (SQLi/XSS/XXE/SSRF/PII/…), seed this file from examples/protect/demo-rules.json — see that folder's README.",
|
|
3
3
|
"firewall": [
|
|
4
4
|
{
|
|
5
|
-
"id": "
|
|
6
|
-
"title": "
|
|
7
|
-
"
|
|
5
|
+
"id": "ps-fallback-proto-pollution",
|
|
6
|
+
"title": "Prototype pollution in request body",
|
|
7
|
+
"category": "prototype-pollution",
|
|
8
8
|
"rule_v2": [
|
|
9
|
+
{ "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "__proto__" } },
|
|
9
10
|
{
|
|
10
|
-
"parameter": "
|
|
11
|
-
"
|
|
12
|
-
|
|
11
|
+
"parameter": "rules",
|
|
12
|
+
"rules": [
|
|
13
|
+
{ "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "constructor" }, "inclusive": true },
|
|
14
|
+
{ "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "prototype" }, "inclusive": true }
|
|
15
|
+
]
|
|
13
16
|
}
|
|
14
17
|
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "ps-fallback-path-traversal",
|
|
21
|
+
"title": "Path traversal in a file/path parameter",
|
|
22
|
+
"category": "lfi",
|
|
23
|
+
"rule_v2": [
|
|
24
|
+
{ "parameter": ["get.file", "post.file", "raw.file", "get.path", "post.path"], "mutations": ["urldecode"], "match": { "type": "contains", "value": ".." } }
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "ps-fallback-ssrf-internal",
|
|
29
|
+
"title": "SSRF via a url parameter pointing at an internal/metadata address",
|
|
30
|
+
"category": "ssrf",
|
|
31
|
+
"rule_v2": [
|
|
32
|
+
{ "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/localhost|127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google/i" } }
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "ps-fallback-xss",
|
|
37
|
+
"title": "Stored/reflected XSS in a text field",
|
|
38
|
+
"category": "xss",
|
|
39
|
+
"rule_v2": [
|
|
40
|
+
{ "parameter": ["post.title", "post.comment", "post.body"], "mutations": ["urldecode", "htmlentitydecode"], "match": { "type": "regex", "value": "/<script\\b|onerror\\s*=|javascript:/i" } }
|
|
41
|
+
]
|
|
15
42
|
}
|
|
16
43
|
],
|
|
17
44
|
"whitelists": [],
|
package/dist/protect.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var runtime_exports = {};
|
|
22
22
|
__export(runtime_exports, {
|
|
23
23
|
GUARD_PATH: () => GUARD_PATH,
|
|
24
|
+
PulseRuleClient: () => PulseRuleClient,
|
|
24
25
|
createProtection: () => createProtection,
|
|
25
26
|
createServerFnGuard: () => createServerFnGuard,
|
|
26
27
|
createSupabaseGuard: () => createSupabaseGuard
|
|
@@ -928,6 +929,64 @@ var RuleEngine = class {
|
|
|
928
929
|
}
|
|
929
930
|
};
|
|
930
931
|
|
|
932
|
+
// src/protect/engine/pulse-client.js
|
|
933
|
+
var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
|
|
934
|
+
var DEFAULT_CACHE_TTL2 = 3e5;
|
|
935
|
+
var PulseRuleClient = class {
|
|
936
|
+
#siteUuid;
|
|
937
|
+
#baseUrl;
|
|
938
|
+
#cacheTtl;
|
|
939
|
+
#cache = null;
|
|
940
|
+
#cacheTime = null;
|
|
941
|
+
constructor({ siteUuid, baseUrl, cacheTtl } = {}) {
|
|
942
|
+
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
943
|
+
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL2;
|
|
944
|
+
this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL2;
|
|
945
|
+
if (!this.#siteUuid) {
|
|
946
|
+
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
async getRules() {
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
if (this.#cache && this.#cacheTime && now - this.#cacheTime < this.#cacheTtl) {
|
|
952
|
+
return this.#cache;
|
|
953
|
+
}
|
|
954
|
+
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
955
|
+
try {
|
|
956
|
+
const response = await fetch(url, {
|
|
957
|
+
method: "GET",
|
|
958
|
+
headers: { Accept: "application/json" },
|
|
959
|
+
signal: AbortSignal.timeout(3e4)
|
|
960
|
+
});
|
|
961
|
+
if (!response.ok) {
|
|
962
|
+
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
963
|
+
}
|
|
964
|
+
const data = await response.json();
|
|
965
|
+
const result = {
|
|
966
|
+
success: true,
|
|
967
|
+
firewall: Array.isArray(data.firewall) ? data.firewall : [],
|
|
968
|
+
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
969
|
+
whitelist_keys: data.whitelist_keys ?? {}
|
|
970
|
+
};
|
|
971
|
+
this.#cache = result;
|
|
972
|
+
this.#cacheTime = now;
|
|
973
|
+
return result;
|
|
974
|
+
} catch (err) {
|
|
975
|
+
return {
|
|
976
|
+
success: false,
|
|
977
|
+
error: err.name === "TimeoutError" ? "Request timed out" : err.message,
|
|
978
|
+
firewall: [],
|
|
979
|
+
whitelists: [],
|
|
980
|
+
whitelist_keys: {}
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
clearCache() {
|
|
985
|
+
this.#cache = null;
|
|
986
|
+
this.#cacheTime = null;
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
931
990
|
// src/protect/engine/fetch.js
|
|
932
991
|
async function fromFetchRequest(request) {
|
|
933
992
|
const url = new URL(request.url);
|
|
@@ -1550,8 +1609,25 @@ function leakResponse() {
|
|
|
1550
1609
|
});
|
|
1551
1610
|
}
|
|
1552
1611
|
async function resolveRules(options) {
|
|
1553
|
-
if (options.
|
|
1554
|
-
|
|
1612
|
+
if (options.siteUuid) {
|
|
1613
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl });
|
|
1614
|
+
const res = await client.getRules();
|
|
1615
|
+
if (res.success) {
|
|
1616
|
+
const bundle = normalizeBundle(res);
|
|
1617
|
+
cacheWrite(options.cacheDir, bundle);
|
|
1618
|
+
return bundle;
|
|
1619
|
+
}
|
|
1620
|
+
const cached = cacheRead(options.cacheDir);
|
|
1621
|
+
if (cached) {
|
|
1622
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error}); using cached bundle`));
|
|
1623
|
+
return normalizeBundle(cached);
|
|
1624
|
+
}
|
|
1625
|
+
if (options.rules) {
|
|
1626
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error}); using bundled fallback`));
|
|
1627
|
+
return normalizeBundle(options.rules);
|
|
1628
|
+
}
|
|
1629
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error}); no cache \u2014 running with no rules`));
|
|
1630
|
+
return emptyBundle();
|
|
1555
1631
|
}
|
|
1556
1632
|
if (options.token) {
|
|
1557
1633
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl });
|
|
@@ -1569,6 +1645,9 @@ async function resolveRules(options) {
|
|
|
1569
1645
|
options.onError?.(new Error(`rule fetch failed (${res.error}); no cache \u2014 running with no rules`));
|
|
1570
1646
|
return emptyBundle();
|
|
1571
1647
|
}
|
|
1648
|
+
if (options.rules) {
|
|
1649
|
+
return normalizeBundle(options.rules);
|
|
1650
|
+
}
|
|
1572
1651
|
return emptyBundle();
|
|
1573
1652
|
}
|
|
1574
1653
|
function normalizeBundle(b) {
|
|
@@ -1620,6 +1699,7 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
1620
1699
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1621
1700
|
0 && (module.exports = {
|
|
1622
1701
|
GUARD_PATH,
|
|
1702
|
+
PulseRuleClient,
|
|
1623
1703
|
createProtection,
|
|
1624
1704
|
createServerFnGuard,
|
|
1625
1705
|
createSupabaseGuard
|