@patchstack/connect 0.3.31 → 0.3.33

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 CHANGED
@@ -144,18 +144,112 @@ would have stopped while it is still in dry-run. Two separate paths, with differ
144
144
  token; the `apiKey` itself is not sent to the log endpoint. Disable with `PATCHSTACK_TELEMETRY=off`, or
145
145
  `reportFirewallLog: false` in `createProtection`.
146
146
  - **Every rule that matched** goes to `monitor/pulse/detections/<your site uuid>` — including matches that
147
- blocked, which are reported on both paths. This is **off unless you pass `reportDetections: true`** to
148
- `createProtection`; the scaffolded guard does not pass it. It also requires a provisioned site UUID, a
149
- resolvable credential, and is disabled by `PATCHSTACK_TELEMETRY=off`. It exists because a rule carrying
150
- `dry-run` blocks nothing, so without it nothing distinguishes a rule that is protecting from one that is
151
- quietly wrong.
152
-
153
- What a detection report contains, per matched rule: the rule id, the request path **with any query string
154
- removed**, the parameter names that rule reads (from the rule's own definition), which phase matched,
155
- whether it was enforced, the identifier of the rule bundle in use, the revision of the rule itself when the
156
- bundle carried one, and a timestamp. Each batch also
157
- carries a count of reports dropped when traffic outran the flush, so a partial sample is not read as a
158
- complete one.
147
+ blocked, which are reported on both paths. It exists because a rule carrying `dry-run` blocks nothing, so
148
+ without it nothing distinguishes a rule that is protecting from one that is quietly wrong.
149
+
150
+ **This is on by default for a site enrolled with Patchstack that is running Patchstack-delivered rules**,
151
+ and off otherwise. Specifically, it requires all of: a provisioned site UUID, rules that came from
152
+ Patchstack rather than from a local bundle, and a resolvable credential. A local install, or a guard
153
+ running its own `rules`, sends nothing.
154
+
155
+ Switch it off with **`PATCHSTACK_REPORT_DETECTIONS=0`**, or `reportDetections: false` in
156
+ `createProtection`, or `PATCHSTACK_TELEMETRY=off` which covers all telemetry. `reportDetections` is an
157
+ opt-out only — passing `true` cannot switch reporting on for a site that is not enrolled.
158
+
159
+ `protection.detectionReporting` names the current state locally, so a guard that is not reporting says
160
+ which reason applies: `on`, `disabled-by-config`, `disabled-by-telemetry-opt-out`, `not-enrolled`,
161
+ `no-managed-rules`, or `unavailable-no-credential`.
162
+
163
+ **How the state reaches Patchstack, and what that costs on the network.** The state travels as a header
164
+ on the rules request the guard already makes — no extra request for it. Two of the six never travel:
165
+ `not-enrolled` makes no site-addressed request at all, and `unavailable-no-credential` cannot produce an
166
+ authenticated one, and the header is withheld from unauthenticated requests. Those two are local
167
+ diagnostics only.
168
+
169
+ There is one case that does add a request. The header is set before the rules request finishes, so a
170
+ guard booting with no cached rules declares `no-managed-rules` and then receives managed rules on that
171
+ same response. When that happens it sends **one immediate POST to the detections endpoint containing the
172
+ corrected state and no detections at all** — an empty `detections` array plus `reporting_state`. It is
173
+ sent once per process, only when the state changed, and never when the guard already had cached rules.
174
+ Without it, a guard with rule refreshing switched off would leave Patchstack holding the pre-resolution
175
+ answer for the life of the process.
176
+
177
+ What a detection report contains, per matched rule — on every phase, whatever fired it: the rule id, the revision of the rule when the
178
+ bundle carried one, the identifier of the rule bundle in use, which phase matched,
179
+ whether it was enforced, the request path **with the query string's values removed**,
180
+ that query's parameter names, the method, and a timestamp. Each batch also carries a count of reports dropped when traffic outran the flush,
181
+ so a partial sample is not read as a complete one.
182
+
183
+ Two fields depend on the phase, because one kind of detection has a client and the other does not. A
184
+ **request or response** detection also carries the user agent,
185
+ and the client address together with where that address came from.
186
+ An **egress** detection — a rule that fired on a call your application made outbound — carries neither:
187
+ the call was your application's own, so there is no visitor to attribute it to, and those fields read
188
+ `null` and `unavailable` rather than being guessed at. "What values a report can contain" below says the
189
+ same thing about captured evidence.
190
+
191
+ Every field is bounded in size, and an event that had to be shortened says so.
192
+ `truncated` lists **which fields were shortened**.
193
+ `parameters_total` records **how many parameters the rule reads**, when a rule reads more than the event
194
+ names.
195
+ `query_keys_total` records **how many query parameters the request carried**, counted as DISTINCT names,
196
+ when it carried more than the event lists — a parameter repeated three times is one name to look up. The
197
+ names themselves are the ones the guard addresses a parameter by, so `?first+name=x` is reported as
198
+ `first name`. Both appear only when something really was shortened, so their absence is not a claim of its own —
199
+ and a shortened route or rule id is marked rather than passed off as complete, because a reader must not
200
+ use one as a key believing it names the whole thing.
201
+
202
+ Delivery is retried, up to four attempts per batch, with exponential backoff and jitter, honouring a
203
+ `Retry-After` header when the endpoint sets one. Only failures worth retrying are retried — unreachable,
204
+ rate-limited, or a server error; a batch that was refused on its merits is not sent again. Every attempt
205
+ of one batch carries the same `Idempotency-Key` header, and a different batch carries a different one, so
206
+ a redelivery is identifiable as the same batch rather than a new one — an acknowledgement can be lost
207
+ after the server has already taken a batch. One request is in flight at a time, so a slow endpoint slows
208
+ the queue rather than opening more sockets; each attempt is abandoned after 10 seconds, so a request that
209
+ never settles cannot hold that slot; and a batch that exhausts its attempts is dropped and counted rather
210
+ than retried forever. Stopping a guard makes one last attempt at whatever is outstanding and counts
211
+ anything it could not send. `stop()` returns a promise that settles once the reporters have finished or
212
+ been given up on, so a shutdown handler can `await protection.stop()` instead of racing the last batch
213
+ against process exit. Each reporter has its own budget, and when it runs out that reporter is ended: its
214
+ requests are aborted, it starts nothing further, and it discards what it was holding. An abort is a
215
+ request to stop, not a guarantee — a transport that ignores it is detached rather than completed, so
216
+ "resolved" means the reporter is finished with it, and a runtime that kills the process still wins
217
+ regardless. Every detection event ends up delivered, refused or dropped and is reported in the health
218
+ counts; block-log records have no counters, so one lost to a failed send or an expired shutdown is
219
+ reported nowhere.
220
+
221
+ The client address is reported with its **provenance**, because an address is only as trustworthy as
222
+ whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed),
223
+ `trusted-proxy` (read from a forwarded header, through peers you declared via `trustedProxy`), or
224
+ `unavailable`. When it is `unavailable` the `client_ip` field is **omitted entirely** rather than sent
225
+ empty, so a missing address cannot read as a failed lookup of a real one. A forwarded header is never
226
+ trusted implicitly: with no `trustedProxy` policy the address is whatever the transport observed, and in a
227
+ runtime that exposes no transport peer there is no address to report at all.
228
+
229
+ ### Behaviour change: how the client address is determined
230
+
231
+ The guard resolves the client address itself, once per request, and shares that one answer with rule
232
+ matching, block logging and detection reports — so those cannot disagree about who a request came from.
233
+ Two consequences if you are upgrading:
234
+
235
+ - **Express and Node: forwarded headers are no longer read implicitly.** Earlier versions took the
236
+ address from `X-Forwarded-For`, `CF-Connecting-IP` or `X-Real-IP` (the Node guard), or from `req.ip`
237
+ (the Express guard, where it reflects Express's own `trust proxy` setting). Neither source can be
238
+ verified by the guard, and any client can send those headers, so both guards now read the transport
239
+ peer. **If your app runs behind a proxy or load balancer, addresses will now show as the proxy's**
240
+ until you declare your proxies with `trustedProxy` (below) — which affects attribution in reports and
241
+ any rule matching on `server.ip` or `REMOTE_ADDR`.
242
+ - **Fetch runtimes report no address at all.** A WHATWG `Request` exposes no transport peer, so a Fetch
243
+ guard (Workers, Deno, Bun, edge) has nothing to observe, and no forwarded header is accepted in its
244
+ place under any `trustedProxy` policy: `client_ip_source` is `unavailable` and no address is sent.
245
+ Earlier versions reported the forwarded header here, so an address-scoped rule that appeared to work on
246
+ such a runtime was matching a client-supplied value.
247
+
248
+ `trustedProxy` is the only way to make a forwarded header count. It takes the proxies you actually run —
249
+ `{ peers: ['10.0.0.0/8'] }`, or `{ hops: 1 }` to trust that many hops in from the peer, plus optional
250
+ `header` and `isTrusted` — and the chain is then read from your application inward, stopping at the first
251
+ hop you have not declared. There are no built-in provider presets: a header a provider sets is
252
+ indistinguishable from one a client sent unless you say which peers may set it.
159
253
 
160
254
  The parameter names are **identifiers, and they name the request region they refer to** — `post.title`,
161
255
  `get.redirect_to`, `cookie.session`, `server.HTTP_AUTHORIZATION`. So a rule that inspects a cookie or an
@@ -163,16 +257,79 @@ The parameter names are **identifiers, and they name the request region they ref
163
257
  definition, not from your traffic, so they describe what is being screened rather than what any request
164
258
  contained.
165
259
 
166
- What it does not contain: **no values of any kind.** Not the value that matched, not the request body,
167
- and not the value of any header, cookie or query-string parameter — including those of the parameters
168
- named above. Reports are batched, capped in memory, and dropped rather than retried if Patchstack cannot
260
+ ### What values a report can contain
261
+
262
+ **A request or response detection** carries the request's method and path,
263
+ the query string's parameter **names**,
264
+ the user agent, the client address with its provenance, and a timestamp.
265
+
266
+ **An egress detection** — a rule that fired on a request your application made outbound — carries the
267
+ outbound method and path, the query's parameter names, and a timestamp. It carries **no user agent and no
268
+ client address**: the call was your application's own, so there is no visitor to attribute it to, and
269
+ those fields read `null` and `unavailable` rather than being guessed at.
270
+
271
+ Beyond that baseline, either can include the **values of the parameters a rule names** — and nothing
272
+ else. Counting that a
273
+ rule fired is not enough to act on it: whoever triages a detection still has to decide whether the request
274
+ was really an attack, and for that they need to see what the rule saw.
275
+
276
+ **A rule earns each permission by naming what it reads.** What may be captured is derived from the rule
277
+ itself, never configured per site:
278
+
279
+ - a rule naming a parameter (`post.title`, `cookie.session`, `server.HTTP_AUTHORIZATION`) permits **that
280
+ parameter's value**, because the rule was written to inspect it;
281
+ - a prefix (`post.field_*`) permits the values of keys that match, and no others;
282
+ - a rule reading `raw` or `all` — the whole request — permits **nothing at all**, so the broadest rules
283
+ grant the narrowest capture;
284
+ - **response** values are never captured: the phase that reads them exists to redact secrets, and
285
+ capturing them would collect the very values that redaction stops leaving;
286
+ - **raw request bytes** need an explicit, reviewed opt-in on the individual rule, and are then limited to
287
+ a short prefix of the body.
288
+
289
+ **Everything is bounded, and the bounds report themselves.**
290
+ At most 10 values per detection, at most 512 characters each, and at most 5 values from any one prefix. A value shortened to fit is marked; values a bound
291
+ left out are counted; a value refused because it was not a plain string, number or boolean is counted
292
+ separately; and a read that failed is counted as a failure rather than as absence — so a short list is
293
+ never mistaken for a complete one.
294
+
295
+ **A `capture.plan` identifies the permissions, not the rule.** Each report carries a `capture.plan` reference derived
296
+ from the permissions themselves — which parameters, which prefixes, which bounds — so what a given report
297
+ was permitted to include can be established after the fact, without the rule in front of you. It
298
+ identifies the PERMISSIONS, not the rule: two different rules that read the same parameters share one
299
+ reference, and the rule document is identified by `rule_id` with `rule_revision`.
300
+
301
+ **Capture is the union of everything the rule reads, not only the condition that matched.** The engine
302
+ reports which rule fired, not which of its conditions did, so a rule reading `post.title` and
303
+ `cookie.session` permits both values whichever one triggered the detection. A rule scoped to one parameter
304
+ captures one; a broad rule captures what it is broad about.
305
+
306
+ **One header value always travels, whatever the rule names: the User-Agent** — on a request or response
307
+ detection, where there is a client to attribute. It is part of the baseline above, because attribution is
308
+ what this channel is for and a detection without it cannot be told from another client's. It is the only
309
+ exception to the rule-scoped policy, and the only header value sent without a rule naming it.
310
+
311
+ **What a report never contains:** the value of any parameter the matched rule does not name — the
312
+ User-Agent above excepted; any response body, header or status value;
313
+ the request body, other than the reviewed raw prefix above;
314
+ and anything at all from a rule that reads the whole request without that opt-in.
315
+
316
+ **One qualification on the query string.** The exclusion above is about baseline URL metadata: `route` and
317
+ `query_keys` describe a URL without disclosing what was in it. It is not a promise about captured
318
+ evidence. A rule that names `egress.url` reads the outbound URL, so its capture carries that URL as the
319
+ rule read it — query values included. That is the rule-scoped policy working as described, not an
320
+ exception to it: the rule named the parameter, so the parameter's value travels. The value recorded is the request as the
321
+ engine resolved it — URL- and entity-decoded — and not the result of a rule's own further mutations.
322
+
323
+ Reports are batched, capped in memory, retried a bounded number of times, and dropped if Patchstack cannot
169
324
  be reached — a reporting failure never delays or fails a request.
170
325
 
171
- The endpoint needs a credential, so `reportDetections: true` with none resolved starts nothing: the guard
172
- warns once at boot and `protection.detectionReporting` reads `unavailable-no-credential` instead of `on`.
326
+ The endpoint needs a credential, so an enrolled site with none resolved starts nothing: the guard warns
327
+ once at boot and `protection.detectionReporting` reads `unavailable-no-credential` instead of `on`.
173
328
  When reporting is on, `protection.detectionHealth()` returns local counts — detections attempted,
174
329
  acknowledged, refused or unreachable, dropped for queue pressure — and the time of the last
175
- acknowledgement. Those counts stay in your process; nothing extra is sent to report them.
330
+ acknowledgement. Counts for the state POST described above are kept separately under `capability`, since it
331
+ carries no detections and would otherwise read as one. Those counts stay in your process; nothing extra is
332
+ sent to report them.
176
333
 
177
334
  `protection.stop()` stops everything the guard has running in the background — the rule-refresh loop, the
178
335
  block-log reporter, the detection reporter — and flushes what is buffered. `protection.stopRefresh()` is
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  Connect a JavaScript / Node.js application to [Patchstack](https://patchstack.com) for continuous vulnerability monitoring. Scans your `package-lock.json` and reports installed packages so Patchstack can match them against its vulnerability database and notify you when something needs patching.
4
4
 
5
- For how this repo fits with the wider Patchstack ecosystem (`saas`, `hub`, `patchstack-website`, `patchstack-connect`), see [`patchstack/saas` → `docs/ecosystem.md`](https://github.com/patchstack/saas/blob/main/docs/ecosystem.md).
6
-
7
5
  ## Agent-assisted setup
8
6
 
9
7
  Copy this request into a coding assistant, or run the same command yourself:
@@ -89,6 +87,7 @@ patchstack-connect demo-guide node-serialize Read-only, state-aware instru
89
87
  local demo, including the next exact command,
90
88
  expected proof, and cleanup.
91
89
  patchstack-connect help Print help
90
+ patchstack-connect --version Print the installed version
92
91
 
93
92
  Options (for scan, setup, and status):
94
93
  --site-uuid <uuid> Override the configured site UUID
@@ -161,6 +160,12 @@ PATCHSTACK_ENVIRONMENT=sandbox npx @patchstack/connect setup
161
160
 
162
161
  The generated `prebuild` scan deliberately carries no hard-coded environment. A production builder with no override reports `production`; a preview/sandbox builder must receive `PATCHSTACK_ENVIRONMENT=sandbox` from its host. Runtime protection itself is not environment-specific: `PATCHSTACK_ENVIRONMENT` labels manifests only. Use `PATCHSTACK_MODE=dry-run` when protection should observe rather than block.
163
162
 
163
+ ### `scan` as a build hook
164
+
165
+ `setup` wires `scan` into `postinstall`, `prebuild`, or the Bun `build` chain. Run from one of those, a report Patchstack cannot accept — no credential in the build environment, a rejected credential, a site that no longer exists, an outage — is printed on stderr and `scan` exits 0, so the install or build it is attached to carries on. Patchstack keeps the last manifest it accepted for the site until a scan that can report. Run directly (`npx @patchstack/connect scan`), the same failure exits 1.
166
+
167
+ A deploy never has `.patchstackrc.local.json`, so the usual cause is a missing `PATCHSTACK_API_KEY` in the platform's environment (see *Configuration*). The hook is recognised through `npm_lifecycle_event`, which npm, pnpm, Yarn and `bun run` set to the running script's name. `bun install` does not set it, so a `postinstall` scan under Bun still fails the install when it cannot report.
168
+
164
169
  ## Production virtual-patch demo
165
170
 
166
171
  The `node-serialize` scenario demonstrates dependency detection and a live, version-scoped virtual patch against a throwaway Express application. Connect/provision the project first, deliberately add the known-vulnerable package, then run:
@@ -84,4 +84,4 @@ export {
84
84
  pulseAuthHeader,
85
85
  pulseFetch
86
86
  };
87
- //# sourceMappingURL=chunk-LLKP5EJS.js.map
87
+ //# sourceMappingURL=chunk-3G2I6QL6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/pulse-token.ts"],"mappings":"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","names":[]}
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "fs";
5
+ import { createRequire as createRequire2 } from "module";
5
6
 
6
7
  // src/parsers/index.ts
7
8
  import { access } from "fs/promises";
@@ -2083,38 +2084,83 @@ var PROD_MARKER_GLOBAL = "__PATCHSTACK_PROD__";
2083
2084
  var REGION_OPEN = "{/* #region patchstack (managed by patchstack-connect \u2014 do not edit) */}";
2084
2085
  var REGION_CLOSE = "{/* #endregion patchstack */}";
2085
2086
  var REGION_RE = /[ \t]*\{\/\* #region patchstack[\s\S]*?#endregion patchstack \*\/\}\n?/g;
2087
+ function findJsxShellAnchor(source, tagName) {
2088
+ const candidates = new RegExp(`^([ \\t]*)<${tagName}(?=[\\s/>])`, "gm");
2089
+ let candidate;
2090
+ while ((candidate = candidates.exec(source)) !== null) {
2091
+ let quote = null;
2092
+ let escaped = false;
2093
+ let braces = 0;
2094
+ let blockComment = false;
2095
+ let lineComment = false;
2096
+ for (let index = candidates.lastIndex; index < source.length; index += 1) {
2097
+ const char = source[index];
2098
+ const next = source[index + 1];
2099
+ if (lineComment) {
2100
+ if (char === "\n") lineComment = false;
2101
+ continue;
2102
+ }
2103
+ if (blockComment) {
2104
+ if (char === "*" && next === "/") {
2105
+ blockComment = false;
2106
+ index += 1;
2107
+ }
2108
+ continue;
2109
+ }
2110
+ if (quote !== null) {
2111
+ if (escaped) {
2112
+ escaped = false;
2113
+ } else if (char === "\\") {
2114
+ escaped = true;
2115
+ } else if (char === quote) {
2116
+ quote = null;
2117
+ }
2118
+ continue;
2119
+ }
2120
+ if (char === "'" || char === '"' || char === "`") {
2121
+ quote = char;
2122
+ } else if (braces > 0 && char === "/" && next === "*") {
2123
+ blockComment = true;
2124
+ index += 1;
2125
+ } else if (braces > 0 && char === "/" && next === "/") {
2126
+ lineComment = true;
2127
+ index += 1;
2128
+ } else if (char === "{") {
2129
+ braces += 1;
2130
+ } else if (char === "}") {
2131
+ if (braces === 0) break;
2132
+ braces -= 1;
2133
+ } else if (char === ">" && braces === 0) {
2134
+ const openingTag = source.slice(candidate.index, index + 1);
2135
+ if (/\/\s*>$/.test(openingTag)) break;
2136
+ return { end: index + 1, indent: candidate[1] ?? "" };
2137
+ }
2138
+ }
2139
+ }
2140
+ return null;
2141
+ }
2086
2142
  function ensureMarkerInJsxShell(source, framework) {
2087
2143
  if (!hasJsxShell(framework)) {
2088
2144
  return { source, action: "unsupported" };
2089
2145
  }
2090
2146
  const stripped = source.replace(REGION_RE, "");
2091
2147
  if (stripped.includes(PROD_MARKER_GLOBAL)) {
2092
- return { source, action: "manual" };
2148
+ return { source: stripped, action: "manual" };
2093
2149
  }
2094
2150
  const block = (indent) => [REGION_OPEN, ...buildSourceMarkerSnippet(framework).split("\n"), REGION_CLOSE].map((line) => `${indent}${line}`).join("\n");
2095
- const widget = stripped.match(/^([ \t]*).*patchstack-widget.*$/m);
2096
- if (widget?.index !== void 0) {
2097
- const indent = widget[1] ?? "";
2151
+ for (const tagName of ["head", "body"]) {
2152
+ const anchor = findJsxShellAnchor(stripped, tagName);
2153
+ if (anchor === null) continue;
2154
+ const indent = `${anchor.indent} `;
2155
+ const remainder = stripped.slice(anchor.end);
2156
+ const separator = remainder.startsWith("\n") || remainder.startsWith("\r") ? "" : "\n";
2098
2157
  return {
2099
- source: `${stripped.slice(0, widget.index)}${block(indent)}
2100
- ${stripped.slice(widget.index)}`,
2158
+ source: `${stripped.slice(0, anchor.end)}
2159
+ ${block(indent)}${separator}${remainder}`,
2101
2160
  action: "added"
2102
2161
  };
2103
2162
  }
2104
- for (const anchor of [/^([ \t]*)<head>[ \t]*$/m, /^([ \t]*)<body>[ \t]*$/m]) {
2105
- const match = stripped.match(anchor);
2106
- if (match?.index === void 0) {
2107
- continue;
2108
- }
2109
- const end = match.index + match[0].length;
2110
- const indent = `${match[1] ?? ""} `;
2111
- return {
2112
- source: `${stripped.slice(0, end)}
2113
- ${block(indent)}${stripped.slice(end)}`,
2114
- action: "added"
2115
- };
2116
- }
2117
- return { source, action: "no-anchor" };
2163
+ return { source: stripped, action: "no-anchor" };
2118
2164
  }
2119
2165
  function ensureSourceMarker(cwd, shell, framework) {
2120
2166
  if (shell === null) {
@@ -2123,7 +2169,7 @@ function ensureSourceMarker(cwd, shell, framework) {
2123
2169
  const file = path7.resolve(cwd, shell);
2124
2170
  const before = readFileSync(file, "utf8");
2125
2171
  const result = ensureMarkerInJsxShell(before, framework);
2126
- if (result.source !== before && result.action === "added") {
2172
+ if (result.source !== before) {
2127
2173
  writeFileSync(file, result.source);
2128
2174
  }
2129
2175
  return { ...result, shell };
@@ -4912,12 +4958,16 @@ var FS_PACKAGES = ["fs-extra", "graceful-fs", "memfs"];
4912
4958
  var isFsPackage = (pkg) => /^node:fs(\/promises)?$/.test(pkg) || FS_PACKAGES.includes(pkg);
4913
4959
  var EXEC_PACKAGES = ["execa", "cross-spawn", "shelljs", "zx"];
4914
4960
  var isExecPackage = (pkg) => pkg === "node:child_process" || EXEC_PACKAGES.includes(pkg);
4961
+ var DESERIALIZE_CALLS = /^unserialize$/;
4962
+ var DESERIALIZE_PACKAGES = ["node-serialize"];
4963
+ var isDeserializePackage = (pkg) => DESERIALIZE_PACKAGES.includes(pkg);
4915
4964
  function recognizedSinkKinds(pkg) {
4916
4965
  const kinds = [];
4917
4966
  if (isDbPackage(pkg)) kinds.push("db");
4918
4967
  if (isFsPackage(pkg)) kinds.push("fs");
4919
4968
  if (isExecPackage(pkg)) kinds.push("exec");
4920
4969
  if (isHttpPackage(pkg)) kinds.push("http");
4970
+ if (isDeserializePackage(pkg)) kinds.push("eval");
4921
4971
  return kinds;
4922
4972
  }
4923
4973
  function collectLocalSinks(sf, ts, bindings, ctx) {
@@ -5031,6 +5081,9 @@ function directSinks(node, ts, bindings, ctx) {
5031
5081
  if (EXEC_CALLS.test(method) && b.pkg && isExecPackage(b.pkg)) {
5032
5082
  push({ kind: "exec", package: b.pkg, op: method, attribution: "import", ...spanOf(n) });
5033
5083
  }
5084
+ if (DESERIALIZE_CALLS.test(method) && b.pkg && isDeserializePackage(b.pkg)) {
5085
+ push({ kind: "eval", provider: b.root, package: b.pkg, op: method, attribution: "import", ...spanOf(n) });
5086
+ }
5034
5087
  if (HTTP_MEMBER_METHODS.has(method)) {
5035
5088
  if (b.pkg && isHttpPackage(b.pkg)) {
5036
5089
  push({ kind: "http", provider: b.root, package: b.pkg, op: method, attribution: "import", ...spanOf(n) });
@@ -5058,6 +5111,9 @@ function directSinks(node, ts, bindings, ctx) {
5058
5111
  push({ kind: "exec", package: pkg, op: name, attribution: "import", ...spanOf(n) });
5059
5112
  }
5060
5113
  if (name === "eval" && trueGlobal) push({ kind: "eval", op: "eval", attribution: "global", ...spanOf(n) });
5114
+ if (DESERIALIZE_CALLS.test(name) && pkg && isDeserializePackage(pkg)) {
5115
+ push({ kind: "eval", package: pkg, op: name, attribution: "import", ...spanOf(n) });
5116
+ }
5061
5117
  }
5062
5118
  }
5063
5119
  if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "Function" && !bindings.locals.has("Function") && !isShadowedByEnclosingBinding(n, "Function", ts)) {
@@ -5134,6 +5190,7 @@ var CANDIDATE_FAMILIES = {
5134
5190
  };
5135
5191
  function argumentRoleOf(sinkKind, method, index, total = 0) {
5136
5192
  if (sinkKind === "eval" && method === "Function") return index === total - 1 ? "code" : "args";
5193
+ if (sinkKind === "eval" && method === "unserialize") return index === 0 ? "code" : "unknown";
5137
5194
  const table = method ? ARGUMENT_ROLES[sinkKind]?.[method] : void 0;
5138
5195
  return table?.[index] ?? "unknown";
5139
5196
  }
@@ -6673,6 +6730,41 @@ function wireBuildScripts(cwd, packageManager) {
6673
6730
  };
6674
6731
  }
6675
6732
 
6733
+ // src/build-hook.ts
6734
+ var INSTALL_AND_BUILD_EVENTS = /* @__PURE__ */ new Set([
6735
+ "preinstall",
6736
+ "install",
6737
+ "postinstall",
6738
+ "prepare",
6739
+ "prebuild",
6740
+ "build",
6741
+ "postbuild"
6742
+ ]);
6743
+ function isInstallOrBuildHook(env = process.env) {
6744
+ const event = env.npm_lifecycle_event;
6745
+ return typeof event === "string" && INSTALL_AND_BUILD_EVENTS.has(event);
6746
+ }
6747
+ function undeliveredReportLines(err, config, cwd) {
6748
+ const lines = [`patchstack: manifest not reported \u2014 ${err.message}`];
6749
+ if (err.code === "UNAUTHORIZED") {
6750
+ const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
6751
+ if (hasCredential) {
6752
+ lines.push(
6753
+ "patchstack: the credential this environment holds was rejected. If `login` rotated it, set the new value as PATCHSTACK_API_KEY here."
6754
+ );
6755
+ } else {
6756
+ lines.push(
6757
+ `patchstack: PATCHSTACK_API_KEY is not set, and neither ${SECRET_CONFIG_FILENAME} nor .patchstackrc.json in ${cwd} holds a credential.`,
6758
+ `patchstack: ${SECRET_CONFIG_FILENAME} is git-ignored, so a clean checkout never has it. Set PATCHSTACK_API_KEY in the platform's environment so builds can report.`
6759
+ );
6760
+ }
6761
+ }
6762
+ lines.push(
6763
+ "patchstack: continuing the build. Patchstack keeps the last manifest it accepted for this site until a scan that can report."
6764
+ );
6765
+ return lines;
6766
+ }
6767
+
6676
6768
  // src/cli.ts
6677
6769
  var HELP = `@patchstack/connect \u2014 scan your lockfile and report packages to Patchstack.
6678
6770
 
@@ -6684,7 +6776,11 @@ Usage:
6684
6776
  disclosure-widget <script> tag in the root
6685
6777
  HTML shell (index.html, public/index.html,
6686
6778
  or src/app.html) \u2014 opt out with
6687
- "widget": false in .patchstackrc.json
6779
+ "widget": false in .patchstackrc.json.
6780
+ Run as a postinstall/prebuild/build hook, a
6781
+ report Patchstack cannot accept is printed
6782
+ and exits 0 so the build goes on; run
6783
+ directly, the same failure exits 1
6688
6784
  patchstack-connect setup [options] Finish the bounded project setup: run scan,
6689
6785
  manage the widget, install + verify runtime
6690
6786
  protection, and wire dependency/build scans.
@@ -6751,6 +6847,10 @@ Usage:
6751
6847
  the old one must be updated. Not usable in CI
6752
6848
  patchstack-connect help Print this message
6753
6849
 
6850
+ Global:
6851
+ --version Print the installed version of this package and exit
6852
+ --help Print this help and exit
6853
+
6754
6854
  Options (for scan, setup, status, and uninstall):
6755
6855
  --site-uuid <uuid> Override the configured site UUID
6756
6856
  --endpoint <url> Override the API endpoint
@@ -6980,7 +7080,14 @@ async function runScan(args, options = {}) {
6980
7080
  if (provisioning) {
6981
7081
  console.log("No site UUID configured \u2014 provisioning a new Patchstack site from this manifest\u2026");
6982
7082
  }
6983
- const response = await postManifest(config, payload);
7083
+ let response;
7084
+ try {
7085
+ response = await postManifest(config, payload);
7086
+ } catch (err) {
7087
+ if (!(err instanceof PatchstackError) || !isInstallOrBuildHook()) throw err;
7088
+ for (const line of undeliveredReportLines(err, config, process.cwd())) console.error(line);
7089
+ return 0;
7090
+ }
6984
7091
  if (provisioning && response.uuid !== void 0 && response.uuid.length > 0) {
6985
7092
  const target = await persistSiteUuid(process.cwd(), response.uuid);
6986
7093
  console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`);
@@ -7445,8 +7552,21 @@ async function runMarkBuild(args) {
7445
7552
  );
7446
7553
  return 0;
7447
7554
  }
7555
+ function packageVersion() {
7556
+ try {
7557
+ const require2 = createRequire2(import.meta.url);
7558
+ const manifest = require2("../package.json");
7559
+ return typeof manifest.version === "string" && manifest.version.length > 0 ? manifest.version : "unknown";
7560
+ } catch {
7561
+ return "unknown";
7562
+ }
7563
+ }
7448
7564
  async function main() {
7449
7565
  const args = parseArgs(process.argv);
7566
+ if (args.flags.has("version") || args.command === "version") {
7567
+ console.log(packageVersion());
7568
+ return 0;
7569
+ }
7450
7570
  if (args.flags.has("help") || args.command === "help" || args.command === null) {
7451
7571
  console.log(HELP);
7452
7572
  return 0;