@patchstack/connect 0.5.1 → 0.5.3
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 +142 -8
- package/README.md +94 -3
- package/dist/{chunk-3G2I6QL6.js → chunk-IOODP4ZB.js} +19 -2
- package/dist/chunk-IOODP4ZB.js.map +1 -0
- package/dist/cli.js +1257 -196
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/protect/runtime/report-listeners.cjs +360 -0
- package/dist/protect/templates/express-guard.cjs +30 -12
- package/dist/protect/templates/express-guard.js +30 -12
- package/dist/protect/templates/express-guard.ts +35 -12
- package/dist/protect/templates/fastify-plugin.cjs +14 -1
- package/dist/protect/templates/fastify-plugin.js +14 -1
- package/dist/protect/templates/fastify-plugin.ts +14 -1
- package/dist/protect/templates/generic-guard.cjs +36 -12
- package/dist/protect/templates/generic-guard.js +36 -12
- package/dist/protect/templates/generic-guard.ts +41 -12
- package/dist/protect.cjs +215 -23
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.cts +40 -0
- package/dist/protect.d.ts +40 -0
- package/dist/protect.edge.js +202 -22
- package/dist/protect.edge.js.map +3 -3
- package/dist/protect.js +192 -25
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-2MMPQN2A.js → refresh-manifest-3QGLYXV5.js} +2 -2
- package/dist/{refresh-manifest-2MMPQN2A.js.map → refresh-manifest-3QGLYXV5.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-3G2I6QL6.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Patchstack runtime protection — GENERIC guard (CommonJS). Managed by `patchstack-connect protect`.
|
|
2
2
|
// Wire whichever helper fits your server into your request path, then run `protect --check`.
|
|
3
|
-
const { createProtection } = require("@patchstack/connect/protect");
|
|
3
|
+
const { createProtection, sentinelAnswer, VERIFY_HEADER } = require("@patchstack/connect/protect");
|
|
4
4
|
// The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
|
|
5
5
|
// throw here is the app failing to boot rather than protection failing open — and a rules file can be
|
|
6
6
|
// absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
|
|
@@ -66,6 +66,12 @@ function psStepAside(err) {
|
|
|
66
66
|
}
|
|
67
67
|
function protectFetch(handler) {
|
|
68
68
|
return async (request, ...rest) => {
|
|
69
|
+
// `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
|
|
70
|
+
// derived from a challenge the verifying process mints per run, which rules out an app matching it
|
|
71
|
+
// by accident, and there is nothing to answer unless that process started this one. Without a
|
|
72
|
+
// challenge in the environment this is a header read.
|
|
73
|
+
const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
|
|
74
|
+
if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
|
|
69
75
|
const active = await getProtection().catch(psStepAside);
|
|
70
76
|
// The handler, and nothing around it: an exception the app throws is the app's, not a protection
|
|
71
77
|
// failure, and must not be read as one or answered twice.
|
|
@@ -92,19 +98,37 @@ function patchstackMiddleware(req, res, next) {
|
|
|
92
98
|
passedOn = true;
|
|
93
99
|
next(err);
|
|
94
100
|
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
// `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
|
|
102
|
+
// before the protection is asked for and before the request is passed on, so no handler of the app's
|
|
103
|
+
// ever sees a verification request. The answer is derived from a challenge the verifying process mints
|
|
104
|
+
// per run, which rules out an app matching it by accident; without a challenge in the environment
|
|
105
|
+
// there is nothing to answer and the request is screened as normal.
|
|
106
|
+
const screen = () => {
|
|
107
|
+
getProtection().then(
|
|
108
|
+
(active) => active.node()(req, res, carryOn),
|
|
109
|
+
(err) => {
|
|
110
|
+
psStepAside(err);
|
|
111
|
+
carryOn();
|
|
112
|
+
},
|
|
113
|
+
).catch((err) => {
|
|
114
|
+
// Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
|
|
115
|
+
// request is carried on only if it never was — an error here must not take the process down and
|
|
116
|
+
// must not answer twice.
|
|
98
117
|
psStepAside(err);
|
|
99
118
|
carryOn();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
|
|
123
|
+
if (answered) {
|
|
124
|
+
res.statusCode = 200;
|
|
125
|
+
res.setHeader("content-type", "text/plain");
|
|
126
|
+
res.end(answered);
|
|
127
|
+
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
screen();
|
|
131
|
+
}, screen);
|
|
108
132
|
}
|
|
109
133
|
|
|
110
134
|
module.exports = { getProtection, protectFetch, patchstackMiddleware };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Patchstack runtime protection — GENERIC guard (ESM). Managed by `patchstack-connect protect`.
|
|
2
2
|
// Wire whichever helper fits your server into your request path, then run `protect --check`.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import { createProtection } from "@patchstack/connect/protect";
|
|
4
|
+
import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
|
|
5
5
|
|
|
6
6
|
// The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
|
|
7
7
|
// throw here is the app failing to boot rather than protection failing open — and a rules file can be
|
|
@@ -68,6 +68,12 @@ function psStepAside(err) {
|
|
|
68
68
|
// Web-Fetch: export default { fetch: protectFetch(originalFetch) }
|
|
69
69
|
export function protectFetch(handler) {
|
|
70
70
|
return async (request, ...rest) => {
|
|
71
|
+
// `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
|
|
72
|
+
// derived from a challenge the verifying process mints per run, which rules out an app matching it
|
|
73
|
+
// by accident, and there is nothing to answer unless that process started this one. Without a
|
|
74
|
+
// challenge in the environment this is a header read.
|
|
75
|
+
const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
|
|
76
|
+
if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
|
|
71
77
|
const active = await getProtection().catch(psStepAside);
|
|
72
78
|
// The handler, and nothing around it: an exception the app throws is the app's, not a protection
|
|
73
79
|
// failure, and must not be read as one or answered twice.
|
|
@@ -94,17 +100,35 @@ export function patchstackMiddleware(req, res, next) {
|
|
|
94
100
|
passedOn = true;
|
|
95
101
|
next(err);
|
|
96
102
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
// `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
|
|
104
|
+
// before the protection is asked for and before the request is passed on, so no handler of the app's
|
|
105
|
+
// ever sees a verification request. The answer is derived from a challenge the verifying process mints
|
|
106
|
+
// per run, which rules out an app matching it by accident; without a challenge in the environment
|
|
107
|
+
// there is nothing to answer and the request is screened as normal.
|
|
108
|
+
const screen = () => {
|
|
109
|
+
getProtection().then(
|
|
110
|
+
(active) => active.node()(req, res, carryOn),
|
|
111
|
+
(err) => {
|
|
112
|
+
psStepAside(err);
|
|
113
|
+
carryOn();
|
|
114
|
+
},
|
|
115
|
+
).catch((err) => {
|
|
116
|
+
// Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
|
|
117
|
+
// request is carried on only if it never was — an error here must not take the process down and
|
|
118
|
+
// must not answer twice.
|
|
100
119
|
psStepAside(err);
|
|
101
120
|
carryOn();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
|
|
125
|
+
if (answered) {
|
|
126
|
+
res.statusCode = 200;
|
|
127
|
+
res.setHeader("content-type", "text/plain");
|
|
128
|
+
res.end(answered);
|
|
129
|
+
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
screen();
|
|
133
|
+
}, screen);
|
|
110
134
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// whichever helper below fits your server into your request path (see the plan the CLI printed),
|
|
5
5
|
// then run `patchstack-connect protect --check` to confirm it's hooked up. The engine ships inside
|
|
6
6
|
// @patchstack/connect — nothing else to install.
|
|
7
|
-
import { createProtection } from "@patchstack/connect/protect";
|
|
7
|
+
import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
|
|
8
8
|
import fallbackRules from "./rules.json";
|
|
9
9
|
|
|
10
10
|
// Baked by `patchstack-connect protect` from .patchstackrc.json when available.
|
|
@@ -76,6 +76,12 @@ function psStepAside(err: unknown) {
|
|
|
76
76
|
// Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
|
|
77
77
|
export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
|
|
78
78
|
return (async (request: Request, ...rest: unknown[]) => {
|
|
79
|
+
// `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
|
|
80
|
+
// derived from a challenge the verifying process mints per run, which rules out an app matching it
|
|
81
|
+
// by accident, and there is nothing to answer unless that process started this one. Without a
|
|
82
|
+
// challenge in the environment this is a header read.
|
|
83
|
+
const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
|
|
84
|
+
if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
|
|
79
85
|
const protection = await getProtection().catch(psStepAside);
|
|
80
86
|
// The handler, and nothing around it: an exception the app throws is the app's, not a protection
|
|
81
87
|
// failure, and must not be read as one or answered twice.
|
|
@@ -103,17 +109,40 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un
|
|
|
103
109
|
passedOn = true;
|
|
104
110
|
next(err);
|
|
105
111
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
// `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
|
|
113
|
+
// before the protection is asked for and before the request is passed on, so no handler of the app's
|
|
114
|
+
// ever sees a verification request. The answer is derived from a challenge the verifying process mints
|
|
115
|
+
// per run, which rules out an app matching it by accident; without a challenge in the environment
|
|
116
|
+
// there is nothing to answer and the request is screened as normal.
|
|
117
|
+
const screen = () => {
|
|
118
|
+
getProtection().then(
|
|
119
|
+
(protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
|
|
120
|
+
(err) => {
|
|
121
|
+
psStepAside(err);
|
|
122
|
+
carryOn();
|
|
123
|
+
},
|
|
124
|
+
).catch((err) => {
|
|
125
|
+
// Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
|
|
126
|
+
// request is carried on only if it never was — an error here must not take the process down and
|
|
127
|
+
// must not answer twice.
|
|
109
128
|
psStepAside(err);
|
|
110
129
|
carryOn();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// The two shapes this seam touches, named rather than asserted wholesale: a request whose headers it
|
|
134
|
+
// reads, and a response it answers on.
|
|
135
|
+
const inbound = req as { headers?: Record<string, unknown> };
|
|
136
|
+
const outbound = res as { statusCode: number; setHeader(name: string, value: string): void; end(body?: string): void };
|
|
137
|
+
|
|
138
|
+
sentinelAnswer(inbound.headers?.[VERIFY_HEADER]).then((answered) => {
|
|
139
|
+
if (answered) {
|
|
140
|
+
outbound.statusCode = 200;
|
|
141
|
+
outbound.setHeader("content-type", "text/plain");
|
|
142
|
+
outbound.end(answered);
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
screen();
|
|
147
|
+
}, screen);
|
|
119
148
|
}
|
package/dist/protect.cjs
CHANGED
|
@@ -123,6 +123,27 @@ var init_pulse_token = __esm({
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
// src/build-id.ts
|
|
127
|
+
function canonicalBuildId(value) {
|
|
128
|
+
if (typeof value !== "string") return null;
|
|
129
|
+
const trimmed = value.trim();
|
|
130
|
+
return BUILD_ID.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
131
|
+
}
|
|
132
|
+
function readBuildStamp(bundle) {
|
|
133
|
+
if (bundle === null || typeof bundle !== "object") return null;
|
|
134
|
+
const namespace = bundle[BUILD_STAMP_KEY];
|
|
135
|
+
if (namespace === null || typeof namespace !== "object") return null;
|
|
136
|
+
return canonicalBuildId(namespace.build_id);
|
|
137
|
+
}
|
|
138
|
+
var BUILD_ID, BUILD_STAMP_KEY;
|
|
139
|
+
var init_build_id = __esm({
|
|
140
|
+
"src/build-id.ts"() {
|
|
141
|
+
"use strict";
|
|
142
|
+
BUILD_ID = /^[0-9a-f]{64}$/i;
|
|
143
|
+
BUILD_STAMP_KEY = "_patchstack";
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
126
147
|
// src/types.ts
|
|
127
148
|
var PatchstackError;
|
|
128
149
|
var init_types = __esm({
|
|
@@ -246,6 +267,7 @@ var init_client = __esm({
|
|
|
246
267
|
"use strict";
|
|
247
268
|
init_types();
|
|
248
269
|
init_pulse_token();
|
|
270
|
+
init_build_id();
|
|
249
271
|
DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
|
|
250
272
|
DEFAULT_TIMEOUT_MS = 3e4;
|
|
251
273
|
CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
|
|
@@ -1845,9 +1867,11 @@ var runtime_exports = {};
|
|
|
1845
1867
|
__export(runtime_exports, {
|
|
1846
1868
|
GUARD_PATH: () => GUARD_PATH,
|
|
1847
1869
|
PulseRuleClient: () => PulseRuleClient,
|
|
1870
|
+
VERIFY_HEADER: () => VERIFY_HEADER,
|
|
1848
1871
|
createProtection: () => createProtection,
|
|
1849
1872
|
createServerFnGuard: () => createServerFnGuard,
|
|
1850
|
-
createSupabaseGuard: () => createSupabaseGuard
|
|
1873
|
+
createSupabaseGuard: () => createSupabaseGuard,
|
|
1874
|
+
sentinelAnswer: () => sentinelAnswer
|
|
1851
1875
|
});
|
|
1852
1876
|
module.exports = __toCommonJS(runtime_exports);
|
|
1853
1877
|
|
|
@@ -3683,6 +3707,18 @@ var RULE_PROPERTY_SHAPES = Object.freeze({
|
|
|
3683
3707
|
});
|
|
3684
3708
|
var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
|
|
3685
3709
|
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
3710
|
+
var BUILD_SCOPE_PROPERTY = "build_scope";
|
|
3711
|
+
var BUILD_SCOPE = Object.freeze({
|
|
3712
|
+
applies_to: Object.freeze(["firewall"]),
|
|
3713
|
+
usable: Object.freeze({
|
|
3714
|
+
type: "string",
|
|
3715
|
+
trim: true,
|
|
3716
|
+
pattern: "^[0-9a-fA-F]{64}$",
|
|
3717
|
+
canonical: "lowercase"
|
|
3718
|
+
}),
|
|
3719
|
+
unreadable: "the rule remains accepted and detects only by default",
|
|
3720
|
+
local_override: "trustLocalRuleScope may explicitly enforce a caller-supplied rule"
|
|
3721
|
+
});
|
|
3686
3722
|
var RULE_PROPERTIES = Object.freeze([
|
|
3687
3723
|
"id",
|
|
3688
3724
|
"rule_id",
|
|
@@ -3702,12 +3738,13 @@ var RULE_PROPERTIES = Object.freeze([
|
|
|
3702
3738
|
"remove_headers",
|
|
3703
3739
|
"cookie_flags",
|
|
3704
3740
|
"ensure",
|
|
3705
|
-
"capture"
|
|
3741
|
+
"capture",
|
|
3742
|
+
BUILD_SCOPE_PROPERTY
|
|
3706
3743
|
]);
|
|
3707
3744
|
var CAPTURE_VERSION = 1;
|
|
3708
3745
|
var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
|
|
3709
3746
|
var CAPTURE_RAW_CHARS_MAX = 512;
|
|
3710
|
-
var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
|
|
3747
|
+
var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture", BUILD_SCOPE_PROPERTY]);
|
|
3711
3748
|
function captureProblem(capture) {
|
|
3712
3749
|
if (capture === void 0 || capture === null) return null;
|
|
3713
3750
|
if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
|
|
@@ -3948,6 +3985,10 @@ function validateBundle(bundle, opts = {}) {
|
|
|
3948
3985
|
rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
|
|
3949
3986
|
continue;
|
|
3950
3987
|
}
|
|
3988
|
+
if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
|
|
3989
|
+
rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
|
|
3990
|
+
continue;
|
|
3991
|
+
}
|
|
3951
3992
|
const reason = conditionsProblem(wl?.rule_v2);
|
|
3952
3993
|
if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
|
|
3953
3994
|
else whitelists.push(wl);
|
|
@@ -4218,8 +4259,11 @@ function asText(value) {
|
|
|
4218
4259
|
|
|
4219
4260
|
// src/protect/engine/pulse-client.js
|
|
4220
4261
|
init_pulse_token();
|
|
4262
|
+
init_build_id();
|
|
4221
4263
|
var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
|
|
4222
4264
|
var DEFAULT_CACHE_TTL2 = 3e5;
|
|
4265
|
+
var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
|
|
4266
|
+
var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
|
|
4223
4267
|
var JITTER_FRACTION2 = 0.1;
|
|
4224
4268
|
var PulseRuleClient = class {
|
|
4225
4269
|
#siteUuid;
|
|
@@ -4232,7 +4276,8 @@ var PulseRuleClient = class {
|
|
|
4232
4276
|
#etag;
|
|
4233
4277
|
#pulseAuth;
|
|
4234
4278
|
#detectionState;
|
|
4235
|
-
|
|
4279
|
+
#buildId;
|
|
4280
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState, buildId } = {}) {
|
|
4236
4281
|
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
4237
4282
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
4238
4283
|
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
@@ -4240,6 +4285,7 @@ var PulseRuleClient = class {
|
|
|
4240
4285
|
this.#etag = etag ?? null;
|
|
4241
4286
|
this.#pulseAuth = pulseAuth ?? null;
|
|
4242
4287
|
this.#detectionState = typeof detectionState === "string" ? detectionState : null;
|
|
4288
|
+
this.#buildId = canonicalBuildId(buildId);
|
|
4243
4289
|
if (!this.#siteUuid) {
|
|
4244
4290
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
4245
4291
|
}
|
|
@@ -4259,11 +4305,23 @@ var PulseRuleClient = class {
|
|
|
4259
4305
|
if (this.#detectionState !== null && typeof auth.Authorization === "string") {
|
|
4260
4306
|
headers["X-Patchstack-Detections"] = this.#detectionState;
|
|
4261
4307
|
}
|
|
4308
|
+
if (this.#buildId !== null && typeof auth.Authorization === "string") {
|
|
4309
|
+
headers["X-Patchstack-Build"] = this.#buildId;
|
|
4310
|
+
}
|
|
4262
4311
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
4263
4312
|
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
4264
4313
|
if (response.status === 304) {
|
|
4265
4314
|
this.#touch(now);
|
|
4266
|
-
|
|
4315
|
+
const result2 = {
|
|
4316
|
+
...this.#cache ?? { firewall: [], whitelists: [], whitelist_keys: {} },
|
|
4317
|
+
success: true,
|
|
4318
|
+
notModified: true,
|
|
4319
|
+
etag: response.headers?.get?.("etag") ?? this.#etag,
|
|
4320
|
+
build: buildVerdictOf(response.headers)
|
|
4321
|
+
};
|
|
4322
|
+
this.#cache = result2;
|
|
4323
|
+
this.#etag = result2.etag;
|
|
4324
|
+
return result2;
|
|
4267
4325
|
}
|
|
4268
4326
|
if (!response.ok) {
|
|
4269
4327
|
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
@@ -4279,7 +4337,10 @@ var PulseRuleClient = class {
|
|
|
4279
4337
|
firewall: data.firewall,
|
|
4280
4338
|
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
4281
4339
|
whitelist_keys: data.whitelist_keys ?? {},
|
|
4282
|
-
...enforcementField(data)
|
|
4340
|
+
...enforcementField(data),
|
|
4341
|
+
// Carried through so the caller can decide what a build-scoped rule may do. Response metadata,
|
|
4342
|
+
// rather than bundle content, because it varies with the build asking and is also present on 304.
|
|
4343
|
+
build: buildVerdictOf(response.headers)
|
|
4283
4344
|
};
|
|
4284
4345
|
this.#cache = result;
|
|
4285
4346
|
this.#etag = result.etag;
|
|
@@ -4305,6 +4366,13 @@ var PulseRuleClient = class {
|
|
|
4305
4366
|
this.#etag = null;
|
|
4306
4367
|
}
|
|
4307
4368
|
};
|
|
4369
|
+
function buildVerdictOf(headers) {
|
|
4370
|
+
const get = headers !== null && typeof headers === "object" && typeof headers.get === "function" ? (name) => headers.get(name) : () => null;
|
|
4371
|
+
const stated2 = get(BUILD_VERDICT_HEADER);
|
|
4372
|
+
const verdict = stated2 === "match" || stated2 === "missing" || stated2 === "mismatch" ? stated2 : "unknown";
|
|
4373
|
+
const matched = get(BUILD_IDENTITY_HEADER);
|
|
4374
|
+
return { verdict, matchedBuildId: typeof matched === "string" ? matched : null };
|
|
4375
|
+
}
|
|
4308
4376
|
function enforcementField(data) {
|
|
4309
4377
|
if (!data || typeof data !== "object") return {};
|
|
4310
4378
|
const v = data.enforcement ?? data.mode;
|
|
@@ -5067,6 +5135,7 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
|
5067
5135
|
}
|
|
5068
5136
|
|
|
5069
5137
|
// src/protect/rules/store.js
|
|
5138
|
+
init_build_id();
|
|
5070
5139
|
var fsMod;
|
|
5071
5140
|
async function loadFs() {
|
|
5072
5141
|
if (fsMod !== void 0) return fsMod;
|
|
@@ -5147,15 +5216,25 @@ async function cacheRead(dir) {
|
|
|
5147
5216
|
function toEnvelope(value) {
|
|
5148
5217
|
if (!value || typeof value !== "object") return null;
|
|
5149
5218
|
if (value.bundle && typeof value.bundle === "object") {
|
|
5150
|
-
|
|
5219
|
+
const buildId = canonicalBuildId(value.buildId);
|
|
5220
|
+
const matched = canonicalBuildId(value.matchedBuildId);
|
|
5221
|
+
return {
|
|
5222
|
+
bundle: value.bundle,
|
|
5223
|
+
etag: value.etag ?? null,
|
|
5224
|
+
buildId,
|
|
5225
|
+
// A confirmation belongs to the presentation stored beside it. A crossed or partially written
|
|
5226
|
+
// envelope confirms nothing, even when one of its fields happens to name the current map.
|
|
5227
|
+
matchedBuildId: buildId !== null && matched === buildId ? matched : null
|
|
5228
|
+
};
|
|
5151
5229
|
}
|
|
5152
5230
|
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
5153
|
-
return { bundle: value, etag: null };
|
|
5231
|
+
return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
|
|
5154
5232
|
}
|
|
5155
5233
|
return null;
|
|
5156
5234
|
}
|
|
5157
5235
|
|
|
5158
5236
|
// src/protect/rules/source.js
|
|
5237
|
+
init_build_id();
|
|
5159
5238
|
function liveUpdateRejections(res, options) {
|
|
5160
5239
|
if (options.acceptPartialBundle) return [];
|
|
5161
5240
|
const { rejected } = validateBundle(
|
|
@@ -5182,62 +5261,146 @@ function fromSource(bundle, origin, reason) {
|
|
|
5182
5261
|
source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
|
|
5183
5262
|
};
|
|
5184
5263
|
}
|
|
5264
|
+
function buildIdentity(options) {
|
|
5265
|
+
if (options.buildId !== void 0 && options.buildId !== null) {
|
|
5266
|
+
const explicit = canonicalBuildId(options.buildId);
|
|
5267
|
+
return explicit === null ? { id: null, reason: "the buildId option is not a complete 64-hex SHA-256 identity" } : { id: explicit };
|
|
5268
|
+
}
|
|
5269
|
+
const stamped = readBuildStamp(options.rules);
|
|
5270
|
+
if (stamped !== null) return { id: stamped };
|
|
5271
|
+
return {
|
|
5272
|
+
id: null,
|
|
5273
|
+
reason: options.rules ? "the guard rules file carries no build identity \u2014 the build did not stamp one" : "this guard was configured without bundled rules, so it carries no build identity"
|
|
5274
|
+
};
|
|
5275
|
+
}
|
|
5276
|
+
function withScopedDryRun(bundle, confirmed, reason, options = {}) {
|
|
5277
|
+
let held = 0;
|
|
5278
|
+
let unusable = 0;
|
|
5279
|
+
const firewall = bundle.firewall.map((rule) => {
|
|
5280
|
+
if (rule === null || typeof rule !== "object") return rule;
|
|
5281
|
+
if (!(BUILD_SCOPE_PROPERTY in rule)) return rule;
|
|
5282
|
+
const scope = rule[BUILD_SCOPE_PROPERTY];
|
|
5283
|
+
const target = canonicalBuildId(scope);
|
|
5284
|
+
if (target !== null && confirmed !== null && target === confirmed) return rule;
|
|
5285
|
+
if (rule.enforcement === "dry-run") return rule;
|
|
5286
|
+
held += 1;
|
|
5287
|
+
if (target === null) unusable += 1;
|
|
5288
|
+
return { ...rule, enforcement: "dry-run" };
|
|
5289
|
+
});
|
|
5290
|
+
if (held === 0) return bundle;
|
|
5291
|
+
const explanations = [
|
|
5292
|
+
...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
|
|
5293
|
+
...held > unusable ? [reason] : []
|
|
5294
|
+
];
|
|
5295
|
+
notify(
|
|
5296
|
+
options.onError,
|
|
5297
|
+
new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
|
|
5298
|
+
"onError"
|
|
5299
|
+
);
|
|
5300
|
+
return { ...bundle, firewall };
|
|
5301
|
+
}
|
|
5302
|
+
function confirmedBy(res, presented) {
|
|
5303
|
+
if (presented === null) return null;
|
|
5304
|
+
const verdict = res?.build;
|
|
5305
|
+
if (verdict?.verdict !== "match") return null;
|
|
5306
|
+
return canonicalBuildId(verdict.matchedBuildId) === presented ? presented : null;
|
|
5307
|
+
}
|
|
5185
5308
|
async function resolveRules(options, store, ctx = {}) {
|
|
5186
5309
|
const timeoutMs = ctx.timeoutMs;
|
|
5187
5310
|
if (options.siteUuid) {
|
|
5188
5311
|
const prior = await store.read();
|
|
5189
|
-
const
|
|
5312
|
+
const identity = buildIdentity(options);
|
|
5313
|
+
const sameBuild = prior?.buildId === identity.id;
|
|
5314
|
+
const cacheConfirmed = identity.id !== null && sameBuild && prior?.matchedBuildId === identity.id ? identity.id : null;
|
|
5315
|
+
const unconfirmed = identity.reason ?? "the platform did not confirm that these coordinates belong to this map";
|
|
5316
|
+
const served = (bundle, confirmed) => withScopedDryRun(bundle, confirmed, unconfirmed, options);
|
|
5317
|
+
const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
|
|
5318
|
+
const client = new PulseRuleClient({
|
|
5319
|
+
siteUuid: options.siteUuid,
|
|
5320
|
+
baseUrl: options.pulseRulesUrl,
|
|
5321
|
+
etag: sameBuild ? prior?.etag : null,
|
|
5322
|
+
timeoutMs,
|
|
5323
|
+
pulseAuth: ctx.pulseAuth,
|
|
5324
|
+
detectionState: ctx.detectionState,
|
|
5325
|
+
buildId: identity.id
|
|
5326
|
+
});
|
|
5190
5327
|
const res = await client.getRules();
|
|
5191
|
-
if (res.success && res.notModified && prior?.bundle)
|
|
5328
|
+
if (res.success && res.notModified && prior?.bundle) {
|
|
5329
|
+
const confirmed = confirmedBy(res, identity.id);
|
|
5330
|
+
await store.write({
|
|
5331
|
+
bundle: prior.bundle,
|
|
5332
|
+
etag: res.etag ?? prior.etag ?? null,
|
|
5333
|
+
buildId: identity.id,
|
|
5334
|
+
matchedBuildId: confirmed
|
|
5335
|
+
});
|
|
5336
|
+
return fromSource(served(normalizeBundle(prior.bundle, options), confirmed), "cache");
|
|
5337
|
+
}
|
|
5192
5338
|
if (res.success && !res.notModified) {
|
|
5339
|
+
const confirmed = confirmedBy(res, identity.id);
|
|
5193
5340
|
const rejected = liveUpdateRejections(res, options);
|
|
5194
5341
|
if (rejected.length > 0) {
|
|
5195
5342
|
reportRejections(rejected, options, "rule update rejected");
|
|
5196
|
-
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
|
|
5197
|
-
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
|
|
5343
|
+
if (prior?.bundle) return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", "update rejected");
|
|
5344
|
+
if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
|
|
5198
5345
|
return fromSource(emptyBundle(), "empty", "update rejected");
|
|
5199
5346
|
}
|
|
5200
5347
|
const bundle = normalizeBundle(res, options);
|
|
5201
|
-
await store.write({ bundle, etag: res.etag ?? null });
|
|
5202
|
-
return fromSource(bundle, "api");
|
|
5348
|
+
await store.write({ bundle, etag: res.etag ?? null, buildId: identity.id, matchedBuildId: confirmed });
|
|
5349
|
+
return fromSource(served(bundle, confirmed), "api");
|
|
5203
5350
|
}
|
|
5204
5351
|
if (prior?.bundle) {
|
|
5205
5352
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
5206
|
-
return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
|
|
5353
|
+
return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", res.error ?? "no usable response");
|
|
5207
5354
|
}
|
|
5208
5355
|
if (options.rules) {
|
|
5209
5356
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
5210
|
-
return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
|
|
5357
|
+
return fromSource(local(normalizeBundle(options.rules, options)), "bundled", res.error ?? "no usable response");
|
|
5211
5358
|
}
|
|
5212
5359
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
5213
5360
|
return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
|
|
5214
5361
|
}
|
|
5215
5362
|
if (options.token) {
|
|
5363
|
+
const identity = buildIdentity(options);
|
|
5364
|
+
const remote = (bundle) => withScopedDryRun(
|
|
5365
|
+
bundle,
|
|
5366
|
+
null,
|
|
5367
|
+
"the token-authenticated rules service did not corroborate this map",
|
|
5368
|
+
options
|
|
5369
|
+
);
|
|
5370
|
+
const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
|
|
5216
5371
|
const prior = await store.read();
|
|
5217
5372
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
5218
5373
|
const res = await client.getRules();
|
|
5219
|
-
if (res.success && res.notModified && prior?.bundle)
|
|
5374
|
+
if (res.success && res.notModified && prior?.bundle) {
|
|
5375
|
+
return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache");
|
|
5376
|
+
}
|
|
5220
5377
|
if (res.success && !res.notModified) {
|
|
5221
5378
|
const rejected = liveUpdateRejections(res, options);
|
|
5222
5379
|
if (rejected.length > 0) {
|
|
5223
5380
|
reportRejections(rejected, options, "rule update rejected");
|
|
5224
|
-
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
|
|
5225
|
-
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
|
|
5381
|
+
if (prior?.bundle) return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", "update rejected");
|
|
5382
|
+
if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
|
|
5226
5383
|
return fromSource(emptyBundle(), "empty", "update rejected");
|
|
5227
5384
|
}
|
|
5228
5385
|
const bundle = normalizeBundle(res, options);
|
|
5229
5386
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
5230
|
-
return fromSource(bundle, "api");
|
|
5387
|
+
return fromSource(remote(bundle), "api");
|
|
5231
5388
|
}
|
|
5232
5389
|
if (prior?.bundle) {
|
|
5233
5390
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
5234
|
-
return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
|
|
5391
|
+
return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", res.error ?? "no usable response");
|
|
5235
5392
|
}
|
|
5236
5393
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
5237
5394
|
return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
|
|
5238
5395
|
}
|
|
5239
5396
|
if (options.rules) {
|
|
5240
|
-
|
|
5397
|
+
const identity = buildIdentity(options);
|
|
5398
|
+
const bundle = normalizeBundle(options.rules, options);
|
|
5399
|
+
if (options.trustLocalRuleScope === true) return fromSource(bundle, "bundled");
|
|
5400
|
+
return fromSource(
|
|
5401
|
+
withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options),
|
|
5402
|
+
"bundled"
|
|
5403
|
+
);
|
|
5241
5404
|
}
|
|
5242
5405
|
return fromSource(emptyBundle(), "empty");
|
|
5243
5406
|
}
|
|
@@ -6330,6 +6493,33 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
|
|
|
6330
6493
|
};
|
|
6331
6494
|
}
|
|
6332
6495
|
|
|
6496
|
+
// src/protect/verify-sentinel.js
|
|
6497
|
+
var VERIFY_HEADER = "x-patchstack-verify";
|
|
6498
|
+
var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
|
|
6499
|
+
function configuredChallenge() {
|
|
6500
|
+
const value = typeof process !== "undefined" ? process.env?.[CHALLENGE] : void 0;
|
|
6501
|
+
return typeof value === "string" && value.length >= 32 ? value : null;
|
|
6502
|
+
}
|
|
6503
|
+
function sameValue(a, b) {
|
|
6504
|
+
if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
|
|
6505
|
+
let differs = 0;
|
|
6506
|
+
for (let i = 0; i < a.length; i += 1) differs |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
6507
|
+
return differs === 0;
|
|
6508
|
+
}
|
|
6509
|
+
async function sentinelAnswer(offered) {
|
|
6510
|
+
const challenge = configuredChallenge();
|
|
6511
|
+
if (challenge === null || !sameValue(challenge, typeof offered === "string" ? offered : null)) return null;
|
|
6512
|
+
const subtle = globalThis.crypto?.subtle;
|
|
6513
|
+
if (!subtle || typeof subtle.digest !== "function" || typeof TextEncoder === "undefined") return null;
|
|
6514
|
+
try {
|
|
6515
|
+
const bytes = new TextEncoder().encode(`patchstack-verify:${challenge}`);
|
|
6516
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
6517
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
6518
|
+
} catch {
|
|
6519
|
+
return null;
|
|
6520
|
+
}
|
|
6521
|
+
}
|
|
6522
|
+
|
|
6333
6523
|
// src/protect/runtime.js
|
|
6334
6524
|
function createServerFnGuard({ protection }) {
|
|
6335
6525
|
const guard = protection.fetchGuard();
|
|
@@ -7667,8 +7857,10 @@ function nodeRequestMeta(req, client) {
|
|
|
7667
7857
|
0 && (module.exports = {
|
|
7668
7858
|
GUARD_PATH,
|
|
7669
7859
|
PulseRuleClient,
|
|
7860
|
+
VERIFY_HEADER,
|
|
7670
7861
|
createProtection,
|
|
7671
7862
|
createServerFnGuard,
|
|
7672
|
-
createSupabaseGuard
|
|
7863
|
+
createSupabaseGuard,
|
|
7864
|
+
sentinelAnswer
|
|
7673
7865
|
});
|
|
7674
7866
|
//# sourceMappingURL=protect.cjs.map
|