securenow 8.25.1 → 8.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NPM_README.md CHANGED
@@ -1472,7 +1472,7 @@ The firewall supports four layers -- Layer 1 is always on, the rest are opt-in:
1472
1472
  | **Layer 1: HTTP** | *(always on)* | Returns 403 (`config.firewall.statusCode`): JSON `{"error":"Forbidden","ip":"..."}` for API clients, a security alert page for browsers. Works with proxy headers. |
1473
1473
  | **Layer 2: TCP** | `config.firewall.tcp=true` | `socket.destroy()` before HTTP parsing -- zero bytes sent back. Sees only the connection's source IP; connections from trusted proxies are left to Layer 1. Checked on each server `connection` **(8.25.0+)**; before, it hooked `listen()` and missed the servers most apps start before the SDK has finished starting |
1474
1474
  | **Layer 3: iptables** | `config.firewall.iptables=true` | Kernel-level DROP in a dedicated `SECURENOW_BLOCK` chain (ip6tables for IPv6), or a `securenow` nft table where iptables is missing **(8.25.0+)**. Linux, root or `CAP_NET_ADMIN`; direct client connections only. 8.25.0 validates every entry, runs without a shell and applies each sync as one batch |
1475
- | **Layer 4: Cloud WAF** | `config.firewall.cloud="cloudflare"` / `"aws"` / `"gcp"` | Pushes the global blocklist to Cloudflare (an account IP list named `securenow-blocklist` that your own WAF rule must reference), AWS WAFv2 (IP set; `npm i @aws-sdk/client-wafv2`) or GCP Cloud Armor (`deny(403)` rules at priority 2000; `npm i @google-cloud/compute`), at most every 30 seconds. `config.firewall.cloudDryRun=true` only logs. 8.25.0 fixes IPv6 ranges, Cloud Armor's 10-range limit and stale rules. Request format tested, not run against real accounts |
1475
+ | **Layer 4: Cloud WAF** | `config.firewall.cloud="cloudflare"` / `"aws"` / `"gcp"` | Pushes the global blocklist to Cloudflare (an account IP list named `securenow-blocklist` that your own WAF rule must reference), AWS WAFv2 (IP set; `npm i @aws-sdk/client-wafv2`) or GCP Cloud Armor (`deny(403)` rules at priority 2000; `npm i @google-cloud/compute`), at most every 30 seconds. `config.firewall.cloudDryRun=true` only logs. 8.25.0 fixes IPv6 ranges, Cloud Armor's 10-range limit and stale rules. AWS tested against real IP sets (8.25.2+); Cloudflare and GCP request format tested only |
1476
1476
 
1477
1477
  Layers 2 to 4 carry the global blocklist; scoped blocks, rate limits and challenges are Layer 1 only. Turn Layers 2 to 4 on with 8.25.0+. In a container, Layer 3 needs `--cap-add NET_ADMIN` and the `iptables` package.
1478
1478
 
@@ -1784,6 +1784,24 @@ app.post('/api/login', async (req, res) => {
1784
1784
 
1785
1785
  `track(type, { userId, sessionId, ip, userAgent, severity, ts, attributes })` is fire-and-forget: it buffers up to 1000 events, sends batches of up to 100 every 2 seconds, and never throws. `flush()` returns a promise for scripts that exit right away. It works in any Node process that can resolve the runtime credentials, with or without the preload.
1786
1786
 
1787
+ Inside a request SecureNow is handling, `ip` and `userAgent` fill themselves when you leave them out (8.26.0+): the address is resolved with the firewall's trusted-proxy rules, so a client can't choose it with `X-Forwarded-For`. That's how a Next.js route handler, which only gets a Web `Request`, reports who connected:
1788
+
1789
+ ```ts
1790
+ // app/api/login/route.ts (Node.js runtime)
1791
+ import { track } from 'securenow/events';
1792
+
1793
+ export async function POST(request: Request) {
1794
+ const { email, password } = await request.json();
1795
+ const user = await findUserByEmail(email);
1796
+ if (!user || !(await verifyPassword(user, password))) {
1797
+ track('auth.login.failure', { userId: user?.id, severity: 'warn', attributes: { reason: 'bad_password' } });
1798
+ return Response.json({ error: 'Invalid email or password' }, { status: 401 });
1799
+ }
1800
+ track('auth.login.success', { userId: user.id });
1801
+ // ...create the session
1802
+ }
1803
+ ```
1804
+
1787
1805
  Recommended auth events: `auth.login.failure`, `auth.login.success`, `auth.otp.send.requested`, `auth.otp.send.result`, `auth.otp.verify.failure`, `auth.otp.verify.success`, `auth.password_reset.requested`, `auth.password_reset.completed`, `auth.mfa.challenge.failure`, `auth.mfa.challenge.success`, `auth.session.revoked`.
1788
1806
 
1789
1807
  Check what arrives with `npx securenow telemetry fields --since 1h --json` (it lists custom event types). From a shell or a non-JS service: `npx securenow event send <type> --user <id> --ip <ip> --attrs k=v`.
package/SKILL-API.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
4
4
 
5
- The integration playbook for the `securenow` npm package. A coding agent that reads only this file can plug SecureNow into a Node.js app, turn on every feature, deploy it and prove it works. It describes 8.25.1; items marked **(8.25.0+)** are new in 8.25.0, so an older install needs `npm install securenow@latest` for them (check with `npx securenow version`). The terminal reference is [SKILL-CLI.md](./SKILL-CLI.md). Both files ship in `node_modules/securenow/`.
5
+ The integration playbook for the `securenow` npm package. A coding agent that reads only this file can plug SecureNow into a Node.js app, turn on every feature, deploy it and prove it works. It describes 8.26.0; items marked **(8.25.0+)** or **(8.26.0+)** are new in that version, so an older install needs `npm install securenow@latest` for them (check with `npx securenow version`). The terminal reference is [SKILL-CLI.md](./SKILL-CLI.md). Both files ship in `node_modules/securenow/`.
6
6
 
7
7
  ## Rules for the agent
8
8
 
@@ -332,7 +332,7 @@ So a `runtime.json` left on a server beats the `credentials.production.json` nex
332
332
 
333
333
  - **What:** one server span per request with method, route (`http.route` where section 3 says so), status code, client IP (`http.client_ip`, `http.client_ip.source`, `http.socket_ip`, `http.proxy.trusted`), redacted headers and body, plus child spans for outgoing HTTP and databases (Postgres, MySQL, MongoDB, Redis...). `service.name` is the app key; `deployment.environment` comes from the config.
334
334
  - **On:** automatic once wired. To skip one instrumentation, list its package name in `config.otel.disableInstrumentations`.
335
- - **Verify:** `npx securenow test-span` prints `Span accepted (HTTP 200)`. Send one request to the app, then `npx securenow traces --env <env> --limit 5` and `npx securenow traces show <traceId> --env <env>`.
335
+ - **Verify:** `npx securenow test-span` prints `Span accepted (HTTP 200)`. Send one request to the app, then `npx securenow traces --env <env> --limit 5` and `npx securenow traces show <traceId> --env <env>` (it starts with the request: route, status, client IP, user agent and the redacted body **(8.26.0+)**). In a project signed in with `securenow login`, these commands use the project's app without `--app` **(8.26.0+)**.
336
336
 
337
337
  ### 5.2 Logs
338
338
 
@@ -384,7 +384,7 @@ So a `runtime.json` left on a server beats the `credentials.production.json` nex
384
384
  | 1 HTTP | always, once the runtime API key is present | 403 JSON `{"error":"Forbidden","ip":"..."}`, or an HTML page with the masked IP for browsers; status from `firewall.statusCode` | Works behind proxies (forwarding headers) |
385
385
  | 2 TCP | `"tcp": true` | Destroys the socket before HTTP parsing, zero bytes sent | Sees only the connection's source IP; connections from trusted proxies are left to layer 1. Use 8.25.0+: before, it hooked `listen()` and missed the servers most apps start before the SDK has finished starting |
386
386
  | 3 OS | `"iptables": true` | Kernel DROP: a `SECURENOW_BLOCK` chain in iptables (ip6tables for IPv6), or a `securenow` nft table where iptables is missing **(8.25.0+)** | Linux, root or `CAP_NET_ADMIN`, `iptables`/`nft` installed; direct client connections only. Use 8.25.0+: it validates every entry, runs without a shell and applies each sync as one batch, and an IPv6 entry no longer stops the sync |
387
- | 4 Cloud WAF | `"cloud": "cloudflare"`, `"aws"` or `"gcp"` | Pushes the global blocklist to the edge, at most every 30 s | Cloudflare: fills an account IP list `securenow-blocklist` that your own WAF rule must reference. AWS: WAFv2 IP set. GCP: Cloud Armor rules at priority 2000, `deny(403)`. Use 8.25.0+ (IPv6 ranges, Cloud Armor's 10-range limit, stale rules fixed). Request format tested, not run against real accounts |
387
+ | 4 Cloud WAF | `"cloud": "cloudflare"`, `"aws"` or `"gcp"` | Pushes the global blocklist to the edge, at most every 30 s | Cloudflare: fills an account IP list `securenow-blocklist` that your own WAF rule must reference. AWS: WAFv2 IP set. GCP: Cloud Armor rules at priority 2000, `deny(403)`. Use 8.25.2+ (IPv6 ranges, Cloud Armor's 10-range limit, stale rules, IPv4-mapped entries fixed). AWS tested against real IP sets; Cloudflare and GCP request format tested only |
388
388
 
389
389
  Layers 2 to 4 carry the global blocklist. Scoped blocks, rate limits and challenges are layer 1 only.
390
390
  - **On:** automatic with the runtime API key. Each app and environment also has a remote switch, `npx securenow firewall enable|disable --app <key> --env production`, applied without a deploy. `config.firewall.enabled` is ignored.
@@ -483,6 +483,24 @@ So a `runtime.json` left on a server beats the `credentials.production.json` nex
483
483
  ```
484
484
 
485
485
  If you also send `sessionId` and want it to match revocations made through the session guard, send what the guard computes, `require('securenow/sessions').sha256(<session cookie value exactly as sent>)`, or give the guard your own `getSessionId` (section 5.10).
486
+
487
+ - **Inside a request, `ip` and `userAgent` fill themselves (8.26.0+).** When `track()` runs while SecureNow is handling a request and you leave `ip` or `userAgent` out, they come from that request: the address resolved with the firewall's trusted-proxy rules (a client can't choose it with `X-Forwarded-For`) and its `User-Agent`. This is how to report events from a Next.js route handler, which only gets a Web `Request` and can't tell who connected. Values you pass win. Outside a request (a script, a queue worker) nothing is added. `require('securenow/resolve-ip').currentClientIp()` returns the same address for your own code (`''` outside a request). It needs the runtime API key (the firewall's request hook carries the request).
488
+
489
+ ```ts
490
+ // Next.js route handler (Node.js runtime), app/api/login/route.ts
491
+ import { track } from 'securenow/events';
492
+
493
+ export async function POST(request: Request) {
494
+ const { email, password } = await request.json();
495
+ const user = await findUserByEmail(email);
496
+ if (!user || !(await verifyPassword(user, password))) {
497
+ track('auth.login.failure', { userId: user?.id, severity: 'warn', attributes: { reason: user ? 'bad_password' : 'unknown_user' } });
498
+ return Response.json({ error: 'Invalid email or password' }, { status: 401 });
499
+ }
500
+ track('auth.login.success', { userId: user.id, attributes: { method: 'password' } });
501
+ // ...create the session
502
+ }
503
+ ```
486
504
  - **Verify:** `npx securenow telemetry fields --since 1h --json` lists the custom event types observed for the app. From a shell or a non-JS service: `npx securenow event send auth.login.failure --user u_test --ip 203.0.113.10 --attrs reason=test` (add `--env local` to tag it local).
487
505
 
488
506
  ### 5.10 Session revocation
@@ -519,7 +537,7 @@ Pick one way to enforce:
519
537
 
520
538
  A revoked request gets 401 with the session cookies cleared: JSON `{"error":"Session revoked. Please sign in again.","code":"session_revoked"}` where `res.json` exists (Express), plain `Session revoked` otherwise. Options: `getSessionId(req)`, `getUserId(req)`, `onRevoked(req, res, next, { sessionId, userId })`, `capture` (default `true`: also sends `session.seen`), `captureWindowMs` (default 300000), `syncIntervalMs` (default 30000). In NestJS, call `app.use(sessions.guard())` in `main.ts`.
521
539
 
522
- 3. **Anything else: `check()` (8.25.0+).** `sessions.check(req, options?)` takes a Node request or a Web `Request` and returns `{ revoked, sessionId, userId }`; you answer 401 yourself. Options: `getSessionId`, `getUserId`, `capture` (default `true`), `ip` (for `session.seen` when `req` is a Web `Request`, which has no socket).
540
+ 3. **Anything else: `check()` (8.25.0+).** `sessions.check(req, options?)` takes a Node request or a Web `Request` and returns `{ revoked, sessionId, userId }`; you answer 401 yourself. Options: `getSessionId`, `getUserId`, `capture` (default `true`), `ip` (for `session.seen`; with a Web `Request`, which has no socket, it defaults to the address of the request SecureNow is handling **(8.26.0+)**).
523
541
 
524
542
  ```js
525
543
  const sessions = require('securenow/sessions');
@@ -771,7 +789,7 @@ Next.js on Vercel goes through `@vercel/otel` (an optional dependency); set `SEC
771
789
  | Config | `npx securenow env` | `Service name` = the app key UUID, the expected `Environment`, `Firewall enabled` (not `missing firewall API key`) |
772
790
  | Connectivity | `npx securenow doctor` | `All checks passed.` (traces, logs, API, firewall sync) |
773
791
  | Ingest | `npx securenow test-span` | `Span accepted (HTTP 200)` |
774
- | App start | start the app | `[securenow] pid=... appId="<app-uuid>" instance=... apiKey=set credentials=<path or env:SECURENOW_CREDENTIALS> → service.name=<app-uuid> ...` (`apiKey=missing`: no runtime API key, so the gateway rejects telemetry and firewall sync; before 8.25.0 the field showed whether the app key was set), `OTel SDK started → https://ingest.securenow.ai/v1/traces`, `Logging: ENABLED`, `Request body capture: ENABLED (max: 10240 bytes, redacting 68 sensitive fields)`, `Firewall: ENABLED`, `Firewall: environment=<env>`, `Firewall: synced <n> global blocked IPs (...) and <n> scoped block rules`. ESM apps first print `ESM loader hook auto-registered (module.register)`. Next.js prints `Next.js App -> service.name=<app-uuid> apiKey=set credentials=<...>` (the `apiKey` and `credentials` fields 8.25.0+), `Environment: <env>`, `OpenTelemetry started for Next.js -> ...` and the same Firewall lines |
792
+ | App start | start the app | `[securenow] pid=... appId="<app-uuid>" instance=... apiKey=set credentials=<path or env:SECURENOW_CREDENTIALS> → service.name=<app-uuid> ...` (`apiKey=missing`: no runtime API key, so the gateway rejects telemetry and firewall sync; before 8.25.0 the field showed whether the app key was set), `OTel SDK started → https://ingest.securenow.ai/v1/traces`, `Logging: ENABLED`, `Request body capture: ENABLED (max: 10240 bytes, redacting 68 sensitive fields)`, `Firewall: ENABLED`, `Firewall: environment=<env>`, `Firewall: synced <n> global blocked IPs (...) and <n> scoped block rules`. ESM apps first print `ESM loader hook auto-registered (module.register)`. Next.js prints `Next.js App -> service.name=<app-uuid> apiKey=set credentials=<...>` (the `apiKey` and `credentials` fields 8.25.0+), `Environment: <env>`, `OpenTelemetry started for Next.js -> ...`, `Request body capture: ENABLED (...)` **(8.26.0+)** and the same Firewall lines |
775
793
  | Traffic | one real request, then `npx securenow traces --env <env> --limit 5` | The request, with `http.client_ip` and (where supported) `http.route` |
776
794
  | Overview | `npx securenow status --env <env>` and `npx securenow today` | The app covered: data flowing, firewall on, detection monitoring |
777
795
  | Firewall | `npx securenow firewall status --env <env>`, `npx securenow firewall test-ip 203.0.113.7 --env <env>` | Firewall on for the app and environment; the verdict for the IP |
@@ -806,7 +824,7 @@ Bad signs: no `[securenow]` line at all (the preload is not on the command that
806
824
 
807
825
  **Not supported:** Edge runtimes (Cloudflare Workers, Vercel Edge, Next.js Edge middleware and routes), Deno, `Bun.serve`, browser telemetry.
808
826
  **Not tested:** Vercel (firewall unverified on its serverless functions), AWS Lambda and other serverless platforms.
809
- **Layers 2 to 4:** turn them on with 8.25.0+ only (in 8.24.0 layer 2 dropped nothing for most apps and layers 3 and 4 mishandled IPv6). **Cloud WAF (layer 4):** request format tested, not run against real Cloudflare, AWS or GCP accounts; on Cloudflare your own WAF rule must reference the `securenow-blocklist` list.
827
+ **Layers 2 to 4:** turn them on with 8.25.0+ only (in 8.24.0 layer 2 dropped nothing for most apps and layers 3 and 4 mishandled IPv6). **Cloud WAF (layer 4):** AWS WAF is tested against real IP sets (IPv4 and IPv6, emptied list) from 8.25.2; Cloudflare and GCP are tested on the request format only, not against real accounts. On Cloudflare your own WAF rule must reference the `securenow-blocklist` list.
810
828
  **IPv6:** the blocklist, allowlist, trusted IPs, rate limits, challenges and API-key IP limits take IPv6 addresses and CIDR ranges, stored in canonical form (`2001:DB8::1` = `2001:db8::1`); layer 1 and trusted proxies match them **(8.25.0+; older SDKs match exact IPv6 addresses but ignore IPv6 ranges)**. A trusted-IP range can be no wider than /8 (IPv4) or /32 (IPv6).
811
829
  **`nuxt dev`:** before 8.25.0 the client IP was unknown, so IP blocking only applied to the built server.
812
830
  **Log redaction (8.25.0+)** goes by field names and value shapes: a secret in free text with no recognizable shape is exported as written.
@@ -824,7 +842,7 @@ Bad signs: no `[securenow]` line at all (the preload is not on the command that
824
842
  | `securenow/events` | `track(type, props)`, `flush()` |
825
843
  | `securenow/sessions` | `guard()`, `capture()`, `isRevoked()`, `start()`, `sha256()`; `check()` **(8.25.0+)** |
826
844
  | `securenow/tracing` | `getLogger(name, version)`, `isLoggingEnabled()` |
827
- | `securenow/resolve-ip` | `resolveClientIp(req)`, `resolveClientIpWithDetails(req)`, `resolveSocketIp(socket)`, `isFromTrustedProxy(ip)` |
845
+ | `securenow/resolve-ip` | `resolveClientIp(req)`, `resolveClientIpWithDetails(req)`, `resolveSocketIp(socket)`, `isFromTrustedProxy(ip)`, `currentClientIp()` **(8.26.0+)** |
828
846
  | `securenow/cidr` | `createMatcher(list)` returns `{ isBlocked(ip), stats() }` for IPv4 and IPv6 addresses and ranges (IPv6 ranges 8.25.0+); `parseCidr`, `parseCidr6`, `parseIp`, `ipToInt`, `matchesCidr` |
829
847
  | `securenow/firewall` | `getStats()`, `getMatcher()`, `getAllowlistMatcher()`, `init()`/`shutdown()` for custom setups |
830
848
  | `securenow/rate-limits` | API helpers: `parseRateLimitText()`, `createRateLimitFromText()`, `createRateLimit()`, `listRateLimits()` (use the CLI or MCP for one-off rules) |
package/cli/challenges.js CHANGED
@@ -5,7 +5,7 @@ const config = require('./config');
5
5
  const ui = require('./ui');
6
6
 
7
7
  function resolveApp(flags) {
8
- return flags.app || config.getDefaultApp();
8
+ return flags.app || config.getDefaultAppKey();
9
9
  }
10
10
 
11
11
  function resolveEnvironment(flags, fallback = null) {
package/cli/config.js CHANGED
@@ -419,6 +419,23 @@ function getDefaultApp() {
419
419
  return loadConfig().defaultApp;
420
420
  }
421
421
 
422
+ // The app a command acts on when no --app is given: the CLI default (`securenow apps default`), else the app this
423
+ // project's SDK reports to (.securenow/runtime.json after `securenow login`), so `traces`, `logs` and the rest work
424
+ // in a freshly signed-in project the way `firewall` and `protect` already did.
425
+ function getDefaultAppKey() {
426
+ const fromConfig = loadConfig().defaultApp;
427
+ if (fromConfig) return fromConfig;
428
+ try {
429
+ const app = getApp();
430
+ if (app && app.key) return String(app.key);
431
+ } catch (_) {}
432
+ try {
433
+ return require('../app-config').resolveAppKey() || null;
434
+ } catch (_) {
435
+ return null;
436
+ }
437
+ }
438
+
422
439
  module.exports = {
423
440
  CONFIG_DIR,
424
441
  CONFIG_FILE,
@@ -459,4 +476,5 @@ module.exports = {
459
476
  getApiUrl,
460
477
  getAppUrl,
461
478
  getDefaultApp,
479
+ getDefaultAppKey,
462
480
  };
@@ -492,8 +492,10 @@ async function doctor(_args, flags) {
492
492
  if (!cfg.appKey) {
493
493
  warnings.push('No app key resolved. Run `npx securenow app connect` or set app.key in .securenow/runtime.json.');
494
494
  }
495
+ // The default and recommended path: an info line, not a warning (it read like a problem on every healthy setup).
496
+ const notes = [];
495
497
  if (cfg.instance === 'https://ingest.securenow.ai') {
496
- warnings.push('Using the SecureNow ingest gateway. Dedicated instances are routed server-side after policy checks.');
498
+ notes.push('Telemetry goes through the SecureNow ingest gateway (the default; dedicated instances are routed server-side).');
497
499
  } else if (cfg.instance === 'https://api.securenow.ai/api/otlp') {
498
500
  warnings.push('Using the legacy SecureNow telemetry gateway path. Regenerate credentials so telemetry uses https://ingest.securenow.ai.');
499
501
  } else if (cfg.instance === 'https://freetrial.securenow.ai:4318') {
@@ -508,7 +510,7 @@ async function doctor(_args, flags) {
508
510
  const ok = checks.every((c) => c.ok);
509
511
 
510
512
  if (flags.json) {
511
- ui.json({ ok, resolved: redactConfig(cfg), checks, warnings });
513
+ ui.json({ ok, resolved: redactConfig(cfg), checks, warnings, notes });
512
514
  process.exit(ok ? 0 : 1);
513
515
  }
514
516
 
@@ -517,6 +519,7 @@ async function doctor(_args, flags) {
517
519
  for (const w of warnings) ui.warn(w);
518
520
  }
519
521
 
522
+ for (const n of notes) ui.info(n);
520
523
  if (singletonOkMessage) ui.success(singletonOkMessage);
521
524
 
522
525
  console.log('');
package/cli/init.js CHANGED
@@ -121,8 +121,14 @@ async function init(_args, flags) {
121
121
 
122
122
  console.log('');
123
123
  ui.success('Setup complete.');
124
- ui.info('Run `npx securenow app connect` if this project is not linked to an app yet.');
125
- ui.info('Then verify with `npx securenow test-span` and `npx securenow status`.');
124
+ let linked = null;
125
+ try { linked = config.getApp(); } catch (_) {}
126
+ if (linked && linked.key) {
127
+ ui.info(`Linked to app ${ui.bold(linked.name || linked.key)}. Verify with \`npx securenow doctor\` and \`npx securenow test-span\`.`);
128
+ } else {
129
+ ui.info('This project is not linked to an app yet: run `npx securenow login` (or `npx securenow app connect`).');
130
+ ui.info('Then verify with `npx securenow test-span` and `npx securenow status`.');
131
+ }
126
132
  }
127
133
 
128
134
  function initCredentials(flags) {
@@ -237,6 +243,15 @@ const nextConfig = {
237
243
  export default nextConfig;
238
244
  `;
239
245
 
246
+ // Where the config object opens. The TypeScript template create-next-app writes for Next.js 15 and 16 annotates it,
247
+ // `const nextConfig: NextConfig = {`: before 8.26.0 only the untyped form matched, so init asked for a manual edit
248
+ // on nearly every new TypeScript app.
249
+ const CONFIG_OBJECT_PATTERNS = [
250
+ /(const\s+nextConfig\s*(?::\s*[\w$.<>[\]| ]+?)?\s*=\s*{\s*\r?\n)/,
251
+ /(export\s+default\s+{\s*\r?\n)/,
252
+ /(module\.exports\s*=\s*{\s*\r?\n)/,
253
+ ];
254
+
240
255
  function patchNextConfig(configPath, major) {
241
256
  const content = fs.readFileSync(configPath, 'utf8');
242
257
  const serverExternalWithSecureNow = /serverExternalPackages\s*:\s*\[[\s\S]*?['"]securenow['"][\s\S]*?\]/m.test(content);
@@ -248,15 +263,15 @@ function patchNextConfig(configPath, major) {
248
263
 
249
264
  if (major < 15) return 'manual';
250
265
 
266
+ // Write in the file's own quote style (create-next-app's TypeScript template uses double quotes).
267
+ const doubles = (content.match(/"/g) || []).length;
268
+ const singles = (content.match(/'/g) || []).length;
269
+ const Q = doubles > singles ? '"' : "'";
270
+
251
271
  function addTraceInclude(nextContent) {
252
272
  if (traceIncludeWithSecureNow || /outputFileTracingIncludes\s*:/.test(nextContent)) return nextContent;
253
- const insert = ` outputFileTracingIncludes: {\n '/*': ['./node_modules/securenow/**/*'],\n },\n`;
254
- const patterns = [
255
- /(const\s+nextConfig\s*=\s*{\s*\r?\n)/,
256
- /(export\s+default\s+{\s*\r?\n)/,
257
- /(module\.exports\s*=\s*{\s*\r?\n)/,
258
- ];
259
- for (const pattern of patterns) {
273
+ const insert = ` outputFileTracingIncludes: {\n ${Q}/*${Q}: [${Q}./node_modules/securenow/**/*${Q}],\n },\n`;
274
+ for (const pattern of CONFIG_OBJECT_PATTERNS) {
260
275
  if (pattern.test(nextContent)) return nextContent.replace(pattern, `$1${insert}`);
261
276
  }
262
277
  return null;
@@ -274,21 +289,15 @@ function patchNextConfig(configPath, major) {
274
289
  const existingServerExternal = content.match(/serverExternalPackages\s*:\s*\[([\s\S]*?)\]/m);
275
290
  if (existingServerExternal) {
276
291
  const current = existingServerExternal[1].trim().replace(/,\s*$/, '');
277
- const replacement = `serverExternalPackages: [${current ? `${current}, ` : ''}'securenow']`;
292
+ const replacement = `serverExternalPackages: [${current ? `${current}, ` : ''}${Q}securenow${Q}]`;
278
293
  let nextContent = content.replace(existingServerExternal[0], replacement);
279
294
  nextContent = addTraceInclude(nextContent) || nextContent;
280
295
  fs.writeFileSync(configPath, nextContent, 'utf8');
281
296
  return 'patched';
282
297
  }
283
298
 
284
- const insert = ` serverExternalPackages: ['securenow'],\n outputFileTracingIncludes: {\n '/*': ['./node_modules/securenow/**/*'],\n },\n`;
285
- const patterns = [
286
- /(const\s+nextConfig\s*=\s*{\s*\r?\n)/,
287
- /(export\s+default\s+{\s*\r?\n)/,
288
- /(module\.exports\s*=\s*{\s*\r?\n)/,
289
- ];
290
-
291
- for (const pattern of patterns) {
299
+ const insert = ` serverExternalPackages: [${Q}securenow${Q}],\n outputFileTracingIncludes: {\n ${Q}/*${Q}: [${Q}./node_modules/securenow/**/*${Q}],\n },\n`;
300
+ for (const pattern of CONFIG_OBJECT_PATTERNS) {
292
301
  if (pattern.test(content)) {
293
302
  fs.writeFileSync(configPath, content.replace(pattern, `$1${insert}`), 'utf8');
294
303
  return 'patched';
package/cli/monitor.js CHANGED
@@ -6,7 +6,7 @@ const ui = require('./ui');
6
6
  const appConfig = require('../app-config');
7
7
 
8
8
  function resolveApp(flags) {
9
- return flags.app || config.getDefaultApp();
9
+ return flags.app || config.getDefaultAppKey();
10
10
  }
11
11
 
12
12
  function resolveEnvironment(flags) {
@@ -21,6 +21,57 @@ function addEnvironment(query, flags) {
21
21
 
22
22
  // ── Traces ──
23
23
 
24
+ // Spans come back as stored (snake_case columns with attribute maps) or, from older APIs, camelCase.
25
+ function spanField(span, ...names) {
26
+ for (const n of names) if (span && span[n] != null && span[n] !== '') return span[n];
27
+ return null;
28
+ }
29
+ function spanAttrs(span) {
30
+ return Object.assign({}, span.attributes_number || {}, span.attributes_bool || {}, span.attributes_string || {}, span.attributes || {});
31
+ }
32
+ function spanStatus(span) {
33
+ const a = spanAttrs(span);
34
+ const code = spanField(span, 'response_status_code', 'responseStatusCode', 'statusCode', 'httpStatusCode') || a['http.status_code'] || a['http.response.status_code'];
35
+ const n = parseInt(code, 10);
36
+ return n >= 100 ? n : null;
37
+ }
38
+ function spanDurationMs(span) {
39
+ const nano = spanField(span, 'duration_nano', 'durationNano');
40
+ if (nano != null) return Number(nano) / 1e6;
41
+ return spanField(span, 'duration');
42
+ }
43
+ function spanKind(span) {
44
+ const s = spanField(span, 'kind_string');
45
+ if (s) return String(s).replace(/^SPAN_KIND_/, '').toLowerCase();
46
+ return { 1: 'internal', 2: 'server', 3: 'client', 4: 'producer', 5: 'consumer' }[spanField(span, 'kind')] || '—';
47
+ }
48
+ function requestSpan(spans) {
49
+ const withBody = spans.find((s) => spanAttrs(s)['http.request.body'] != null);
50
+ if (withBody) return withBody;
51
+ const withIp = spans.find((s) => spanAttrs(s)['http.client_ip']);
52
+ if (withIp) return withIp;
53
+ return spans.find((s) => !spanField(s, 'parent_span_id', 'parentSpanID', 'parentSpanId')) || null;
54
+ }
55
+ function headerValue(headersJson, name) {
56
+ try { return JSON.parse(headersJson)[name] || null; } catch (_) { return null; }
57
+ }
58
+
59
+ // One row per trace: when a trace has several server spans (Next.js wraps the HTTP span in its own), keep the
60
+ // longest one, which is the whole request as the client saw it. Order is kept.
61
+ function onePerTrace(spans) {
62
+ const best = new Map();
63
+ const order = [];
64
+ for (const t of spans) {
65
+ const id = t.traceID || t.traceId || t._id;
66
+ if (!id) { order.push(t); continue; }
67
+ const dur = (x) => Number(x.durationNano || 0) || Number(x.duration || 0) * 1e6;
68
+ const prev = best.get(id);
69
+ if (!prev) { best.set(id, t); order.push(id); }
70
+ else if (dur(t) > dur(prev)) best.set(id, t);
71
+ }
72
+ return order.map((k) => (typeof k === 'string' ? best.get(k) : k));
73
+ }
74
+
24
75
  async function tracesList(args, flags) {
25
76
  requireAuth();
26
77
  const appKey = resolveApp(flags);
@@ -31,33 +82,37 @@ async function tracesList(args, flags) {
31
82
 
32
83
  const s = ui.spinner('Fetching traces');
33
84
  try {
85
+ const limit = Math.max(1, parseInt(flags.limit, 10) || 20);
34
86
  const query = {
35
87
  appKeys: appKey,
36
- limit: flags.limit || 20,
88
+ // Ask for more server spans than rows: one request can have two (Next.js records its own span next to
89
+ // the HTTP one), and they collapse into one row below.
90
+ limit: Math.min(limit * 3, 1000),
37
91
  };
38
92
  addEnvironment(query, flags);
39
93
  if (flags.start) query.from = flags.start;
40
94
  if (flags.end) query.to = flags.end;
41
95
 
42
96
  const data = await api.get('/traces', { query });
43
- const traces = data.traces || [];
97
+ const traces = onePerTrace(data.traces || []).slice(0, limit);
44
98
  s.stop(`Found ${traces.length} trace${traces.length !== 1 ? 's' : ''}`);
45
99
 
46
100
  if (flags.json) { ui.json(traces); return; }
47
101
 
48
102
  console.log('');
103
+ // Full trace ids: `securenow traces show <id>` needs the whole id.
49
104
  const rows = traces.map(t => [
50
- ui.c.dim(ui.truncate(t.traceID || t.traceId || t._id, 16)),
105
+ ui.c.dim(String(t.traceID || t.traceId || t._id || '')),
51
106
  t.environment || 'production',
52
- t.operationName || t.name || t.serviceName || '—',
107
+ t.httpMethod || t.method || '—',
108
+ ui.truncate(t.operationName || t.name || t.httpRoute || t.httpUrl || t.url || '—', 40),
53
109
  ui.httpStatusColor(t.statusCode || t.httpStatusCode || t.responseStatusCode || '—'),
54
110
  ui.durationColor(t.durationNano ? t.durationNano / 1e6 : t.duration),
55
- t.httpMethod || t.method || '—',
56
- ui.truncate(t.httpUrl || t.url || t.httpRoute || '', 40),
111
+ t.clientIp || '—',
57
112
  ui.timeAgo(t.timestamp),
58
113
  ]);
59
114
 
60
- ui.table(['Trace ID', 'Env', 'Operation', 'Status', 'Duration', 'Method', 'URL', 'Time'], rows);
115
+ ui.table(['Trace ID', 'Env', 'Method', 'Path', 'Status', 'Duration', 'Client IP', 'Time'], rows);
61
116
  console.log('');
62
117
  } catch (err) {
63
118
  s.fail('Failed to fetch traces');
@@ -88,14 +143,36 @@ async function tracesShow(args, flags) {
88
143
  ui.heading(`Trace ${data.traceId || traceId}`);
89
144
 
90
145
  if (spans.length) {
146
+ // The request as SecureNow stored it: the server span that carries the HTTP attributes.
147
+ const request = requestSpan(spans);
148
+ if (request) {
149
+ const a = spanAttrs(request);
150
+ const ip = a['http.client_ip'];
151
+ const pairs = [
152
+ ['Request', `${a['http.method'] || spanField(request, 'http_method', 'httpMethod') || ''} ${a['http.route'] || a['http.target'] || ''}`.trim() || '—'],
153
+ ['Status', spanStatus(request) ? ui.httpStatusColor(spanStatus(request)) : '—'],
154
+ ['Duration', ui.durationColor(spanDurationMs(request))],
155
+ ['Client IP', ip ? `${ip}${a['http.client_ip.source'] ? ui.c.dim(` (${a['http.client_ip.source']})`) : ''}` : '—'],
156
+ ];
157
+ const ua = a['http.user_agent'] || a['user_agent.original'] || headerValue(a['http.request.headers'], 'user-agent');
158
+ if (ua) pairs.push(['User agent', ua]);
159
+ if (a['http.request.body']) {
160
+ const kind = [a['http.request.body.type'], a['http.request.body.size'] != null ? `${a['http.request.body.size']} bytes` : ''].filter(Boolean).join(', ');
161
+ pairs.push(['Body', `${a['http.request.body']}${kind ? ui.c.dim(` (${kind})`) : ''}`]);
162
+ }
163
+ if (a['http.request.headers']) pairs.push(['Headers', ui.c.dim(a['http.request.headers'])]);
164
+ console.log('');
165
+ ui.keyValue(pairs);
166
+ }
167
+
91
168
  ui.subheading(`Spans (${spans.length})`);
92
169
  console.log('');
93
170
  const rows = spans.map(span => [
94
- ui.c.dim(ui.truncate(span.spanID || span.spanId, 16)),
95
- span.operationName || span.name || '—',
96
- ui.httpStatusColor(span.statusCode || span.responseStatusCode || '—'),
97
- ui.durationColor(span.durationNano ? span.durationNano / 1e6 : span.duration),
98
- span.kind || '—',
171
+ ui.c.dim(ui.truncate(spanField(span, 'span_id', 'spanID', 'spanId') || '', 16)),
172
+ spanField(span, 'operationName', 'name') || '—',
173
+ spanStatus(span) ? ui.httpStatusColor(spanStatus(span)) : ui.c.dim('—'),
174
+ ui.durationColor(spanDurationMs(span)),
175
+ spanKind(span),
99
176
  ]);
100
177
  ui.table(['Span ID', 'Operation', 'Status', 'Duration', 'Kind'], rows);
101
178
  } else {
package/cli/rateLimits.js CHANGED
@@ -5,7 +5,7 @@ const config = require('./config');
5
5
  const ui = require('./ui');
6
6
 
7
7
  function resolveApp(flags) {
8
- return flags.app || config.getDefaultApp();
8
+ return flags.app || config.getDefaultAppKey();
9
9
  }
10
10
 
11
11
  function resolveEnvironment(flags, fallback = null) {
package/cli/security.js CHANGED
@@ -6,7 +6,7 @@ const config = require('./config');
6
6
  const ui = require('./ui');
7
7
 
8
8
  function resolveApp(flags) {
9
- return flags.app || config.getDefaultApp();
9
+ return flags.app || config.getDefaultAppKey();
10
10
  }
11
11
 
12
12
  function resolveEnvironment(flags, fallback = null) {
@@ -1962,7 +1962,7 @@ async function trustedRemove(args, flags) {
1962
1962
  // ── Helpers ──
1963
1963
 
1964
1964
  async function resolveAppId(flags) {
1965
- const appKey = flags.app || config.getDefaultApp();
1965
+ const appKey = flags.app || config.getDefaultAppKey();
1966
1966
  if (!appKey) return null;
1967
1967
  const data = await api.get('/applications');
1968
1968
  const apps = data.applications || [];
package/cli/telemetry.js CHANGED
@@ -108,7 +108,7 @@ async function schema(args, flags) {
108
108
 
109
109
  async function fields(args, flags) {
110
110
  requireAuth();
111
- const appKey = flags.app || config.getDefaultApp() || appConfig.resolveAppKey();
111
+ const appKey = flags.app || config.getDefaultAppKey();
112
112
  if (!appKey) {
113
113
  ui.error('No app specified. Use --app <key>, run `securenow app connect`, or set a default app.');
114
114
  process.exitCode = 1;
package/events.d.ts CHANGED
@@ -3,9 +3,12 @@ export interface TrackProps {
3
3
  userId?: string | number;
4
4
  /** Session identifier (finer-grained than userId). */
5
5
  sessionId?: string | number;
6
- /** End-user client IP as seen by your app (enriched to geo/ASN server-side). */
6
+ /**
7
+ * End-user client IP (enriched to geo/ASN server-side). Inside a request SecureNow is handling, defaults to
8
+ * that request's address, resolved with the firewall's trusted-proxy rules (8.26.0+).
9
+ */
7
10
  ip?: string;
8
- /** End-user agent string. */
11
+ /** End-user agent string. Inside a request, defaults to its User-Agent header (8.26.0+). */
9
12
  userAgent?: string;
10
13
  /** Severity hint. Defaults to "info". */
11
14
  severity?: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
package/events.js CHANGED
@@ -129,6 +129,24 @@ function normalizeEvent(type, props) {
129
129
  ev.attributes = props.attributes;
130
130
  }
131
131
  }
132
+ // Inside a request SecureNow is handling, the visitor's address and user agent come from that request
133
+ // when the caller leaves them out (8.26.0+): a Next.js route handler only has a Web Request, which can't
134
+ // tell who connected. The address is resolved like the firewall's, so X-Forwarded-For can't choose it.
135
+ if (ev.ip == null || ev.user_agent == null) {
136
+ try {
137
+ const requestContext = require('./request-context');
138
+ if (ev.ip == null) {
139
+ const ip = requestContext.currentClientIp();
140
+ if (ip) ev.ip = ip;
141
+ }
142
+ if (ev.user_agent == null) {
143
+ const ua = requestContext.currentUserAgent();
144
+ if (ua) ev.user_agent = ua;
145
+ }
146
+ } catch {
147
+ /* never throw into the app */
148
+ }
149
+ }
132
150
  return ev;
133
151
  }
134
152
 
@@ -136,6 +154,7 @@ function normalizeEvent(type, props) {
136
154
  * Emit a custom event. Fire-and-forget — returns immediately, never throws.
137
155
  * @param {string} type Event type, e.g. "auth.login.success".
138
156
  * @param {object} [props] { userId, sessionId, ip, userAgent, severity, ts, attributes }
157
+ * Inside a request, ip and userAgent default to the request's (8.26.0+).
139
158
  */
140
159
  function track(type, props = {}) {
141
160
  try {
package/firewall-cloud.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  const https = require('https');
14
- const net = require('net');
14
+ const { parseIp, parseCidr, parseCidr6, intToIp, bigIntToIpv6 } = require('./cidr');
15
15
 
16
16
  let _options = null;
17
17
  let _active = false;
@@ -24,21 +24,38 @@ const GCP_MAX_RULES = 100;
24
24
 
25
25
  const MIN_PUSH_INTERVAL_MS = 30000;
26
26
 
27
- // Blocklist entries as CIDRs, per address family: an exact address becomes /32 (IPv4) or /128 (IPv6).
28
- // (Appending "/32" to everything turned one IPv6 address into a /32 covering billions of addresses.)
27
+ // Blocklist entries as canonical CIDRs, per address family: an exact address becomes /32 (IPv4) or /128
28
+ // (IPv6), an IPv4-mapped address (::ffff:1.2.3.4) is IPv4, a range is written from its network address,
29
+ // and anything a provider would refuse (not an IP, a /0) is left out. One bad entry used to make the
30
+ // provider refuse the whole push; the app still blocks what is left out here.
29
31
  function toCidrs(ips) {
30
- const v4 = [];
31
- const v6 = [];
32
+ const v4 = new Set();
33
+ const v6 = new Set();
32
34
  for (const raw of ips || []) {
33
35
  const entry = String(raw || '').trim();
34
36
  if (!entry) continue;
35
- const slash = entry.indexOf('/');
36
- const addr = slash === -1 ? entry : entry.slice(0, slash);
37
- const family = net.isIP(addr);
38
- if (family === 4) v4.push(slash === -1 ? `${addr}/32` : entry);
39
- else if (family === 6) v6.push(slash === -1 ? `${addr}/128` : entry);
37
+ if (!entry.includes('/')) {
38
+ const ip = parseIp(entry);
39
+ if (ip) (ip.family === 4 ? v4 : v6).add(`${ip.key}/${ip.family === 4 ? 32 : 128}`);
40
+ continue;
41
+ }
42
+ const bits = Number(entry.slice(entry.indexOf('/') + 1));
43
+ const c4 = parseCidr(entry);
44
+ if (c4) {
45
+ if (bits > 0) v4.add(`${intToIp(c4.network)}/${bits}`);
46
+ continue;
47
+ }
48
+ const c6 = parseCidr6(entry);
49
+ if (!c6 || c6.bits === 0) continue;
50
+ if (c6.bits >= 96 && (c6.network >> 32n) === 0xffffn) {
51
+ // ::ffff:0:0/96 and narrower are IPv4 ranges written in IPv6 form.
52
+ const b4 = c6.bits - 96;
53
+ if (b4 > 0) v4.add(`${intToIp(Number(c6.network & 0xffffffffn) >>> 0)}/${b4}`);
54
+ } else {
55
+ v6.add(`${bigIntToIpv6(c6.network)}/${c6.bits}`);
56
+ }
40
57
  }
41
- return { v4, v6 };
58
+ return { v4: [...v4], v6: [...v6] };
42
59
  }
43
60
 
44
61
  function prefixLength(cidr) {
package/firewall.js CHANGED
@@ -5,6 +5,7 @@ const https = require('https');
5
5
  const crypto = require('crypto');
6
6
  const { createMatcher } = require('./cidr');
7
7
  const { resolveClientIp } = require('./resolve-ip');
8
+ const requestContext = require('./request-context');
8
9
  const challenge = require('./challenge');
9
10
 
10
11
  // Sent with every firewall API call, so SecureNow can tell which SDK versions and how many processes run.
@@ -480,9 +481,12 @@ function doUnifiedSync(callback) {
480
481
  }
481
482
 
482
483
  if (body.allowlistIps) {
484
+ // The API can resend an unchanged (often empty) allowlist on every sync: only a real change counts, or
485
+ // "re-synced 0 allowed IPs" was logged, and exported as a log record, every 10 seconds.
486
+ const before = _allowlistRawIps ? JSON.stringify(_allowlistRawIps) : null;
483
487
  _allowlistRawIps = body.allowlistIps;
484
488
  _allowlistMatcher = createMatcher(body.allowlistIps);
485
- alChanged = true;
489
+ alChanged = before !== JSON.stringify(body.allowlistIps);
486
490
  }
487
491
 
488
492
  if (body.rateLimits) {
@@ -1463,13 +1467,20 @@ function runRequestHooks(req, res) {
1463
1467
  }
1464
1468
 
1465
1469
  // One 'request' guard for every server kind: the firewall runs once per request, then the server's own emit.
1470
+ // The app's handler runs inside the request context (request-context.js), so track() and sessions.check()
1471
+ // can find the visitor's address from code that only gets a Web Request (Next.js route handlers).
1466
1472
  function guardedEmit(originalEmit) {
1467
1473
  return function securenowGuardedEmit(event, req, res, ...rest) {
1468
1474
  if (event === 'connection' && _tcpLayer && req && _tcpLayer.checkConnection(req)) return true;
1469
- if (event === 'request' && req && res && !res.headersSent && !req[FIREWALL_CHECKED]) {
1470
- req[FIREWALL_CHECKED] = true;
1471
- if (firewallRequestHandler(req, res)) return true;
1472
- if (_requestHooks.length && runRequestHooks(req, res)) return true;
1475
+ if (event === 'request' && req && res) {
1476
+ return requestContext.runWithRequest(req, () => {
1477
+ if (!res.headersSent && !req[FIREWALL_CHECKED]) {
1478
+ req[FIREWALL_CHECKED] = true;
1479
+ if (firewallRequestHandler(req, res)) return true;
1480
+ if (_requestHooks.length && runRequestHooks(req, res)) return true;
1481
+ }
1482
+ return originalEmit.call(this, event, req, res, ...rest);
1483
+ });
1473
1484
  }
1474
1485
  return originalEmit.call(this, event, req, res, ...rest);
1475
1486
  };
package/nextjs.js CHANGED
@@ -156,7 +156,7 @@ function registerSecureNow(options = {}) {
156
156
 
157
157
  // -------- Log environment detection --------
158
158
  if (!isVercel) {
159
- console.log('[securenow] Self-hosted environment detected (EC2/PM2) - using vanilla SDK');
159
+ console.log('[securenow] Node.js server (next start or standalone) - using the OpenTelemetry Node SDK');
160
160
  }
161
161
 
162
162
  // -------- Use different initialization based on environment --------
@@ -458,7 +458,7 @@ function registerSecureNow(options = {}) {
458
458
  });
459
459
 
460
460
  sdk.start();
461
- console.log('[securenow] Vanilla SDK initialized for self-hosted environment');
461
+ console.log('[securenow] OpenTelemetry Node SDK initialized');
462
462
 
463
463
  // -------- Logging (self-hosted only) --------
464
464
  // Opt-out default: set config.logging.enabled=false to disable.
@@ -595,14 +595,16 @@ function registerSecureNow(options = {}) {
595
595
  isRegistered = true;
596
596
 
597
597
  console.log('[securenow] OpenTelemetry started for Next.js -> %s', tracesUrl);
598
- console.log('[securenow] Auto-capturing comprehensive request metadata:');
599
- console.log('[securenow] - IP addresses (x-forwarded-for, x-real-ip, socket)');
600
- console.log('[securenow] - User-Agent, Referer, Origin, Accept headers');
601
- console.log('[securenow] - Protocol, Host, Port (proxy-aware)');
602
- console.log('[securenow] - Geographic data (Vercel/Cloudflare)');
603
- console.log('[securenow] - Request IDs, CSRF tokens, Auth presence');
604
- console.log('[securenow] - Response status, content-type, content-length');
605
- console.log('[securenow] - Request headers (redacted)%s', captureBody ? ' and JSON/form/GraphQL bodies (redacted)' : '');
598
+ console.log('[securenow] Capturing: client IP (trusted proxies only), method, route, status, user agent, headers (redacted)');
599
+ // Same line as -r securenow/register prints, so one checklist works for every framework.
600
+ if (captureBody) {
601
+ console.log('[securenow] Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', requestCapture.maxBodySize, requestCapture.allSensitiveFields.length);
602
+ } else {
603
+ console.log('[securenow] Request body capture: DISABLED (config.capture.body=false)');
604
+ }
605
+ if (requestCapture.captureMultipart) {
606
+ console.log('[securenow] Multipart capture: ENABLED (field names and file metadata, never file content)');
607
+ }
606
608
 
607
609
  // Optional test span
608
610
  if (appConfig.boolConfig('runtime.testSpan', false)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.25.1",
3
+ "version": "8.26.0",
4
4
  "mcpName": "ai.securenow/securenow",
5
5
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
6
6
  "type": "commonjs",
@@ -11,7 +11,7 @@
11
11
  "securenow-mcp": "mcp/server.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test-app-config.js && node test-resolve-ip.js && node test-cidr.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-firewall-guards.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-log-redaction.js && node test-crash-capture.js && node test-crash-flush.js && node test-request-capture.js && node test-fastify-hooks.js && node test-pm2-credentials.js && node test-env-credentials.js && node test-framework-wiring.js && node test-framework-routes.js && node test-sessions-everywhere.js && node test-local-socket.js && node test-firewall-cloud.js && node test-firewall-layers.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
14
+ "test": "node test-app-config.js && node test-resolve-ip.js && node test-cidr.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-firewall-guards.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-log-redaction.js && node test-crash-capture.js && node test-crash-flush.js && node test-request-capture.js && node test-fastify-hooks.js && node test-pm2-credentials.js && node test-env-credentials.js && node test-framework-wiring.js && node test-framework-routes.js && node test-sessions-everywhere.js && node test-request-context.js && node test-local-socket.js && node test-firewall-cloud.js && node test-firewall-layers.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
15
15
  },
16
16
  "keywords": [
17
17
  "opentelemetry",
@@ -155,6 +155,7 @@
155
155
  "cli/",
156
156
  "mcp/",
157
157
  "resolve-ip.js",
158
+ "request-context.js",
158
159
  "cidr.js",
159
160
  "firewall.js",
160
161
  "challenge.js",
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ // The incoming request SecureNow is handling right now, for code that runs inside it.
4
+ //
5
+ // The firewall's request guard enters this context once per incoming request, and AsyncLocalStorage
6
+ // carries it through every await of the app's handler. So code that never sees the Node request can
7
+ // still reach it: a Next.js route handler gets a Web Request with no socket, which is not enough to
8
+ // know who connected. track() and sessions.check() use it to fill the visitor's address (resolved with
9
+ // the same trusted-proxy rules as the firewall, so a client can't choose it with X-Forwarded-For) and
10
+ // the user agent when the caller doesn't pass them.
11
+ //
12
+ // const { currentClientIp } = require('securenow/resolve-ip');
13
+ // currentClientIp(); // '203.0.113.7' inside a request, '' outside one
14
+ //
15
+ // The store lives on globalThis so a second copy of the package (CommonJS and ESM loads, a bundled
16
+ // copy) still sees the request the first copy entered.
17
+
18
+ const KEY = Symbol.for('securenow.requestContext');
19
+
20
+ function store() {
21
+ let als = globalThis[KEY];
22
+ if (!als) {
23
+ try {
24
+ const { AsyncLocalStorage } = require('async_hooks');
25
+ als = new AsyncLocalStorage();
26
+ } catch (_) {
27
+ als = null;
28
+ }
29
+ globalThis[KEY] = als;
30
+ }
31
+ return als;
32
+ }
33
+
34
+ /** Run fn with req as the current request. Returns what fn returns. */
35
+ function runWithRequest(req, fn) {
36
+ const als = store();
37
+ if (!als || !req) return fn();
38
+ return als.run(req, fn);
39
+ }
40
+
41
+ /** The Node request being handled in this async context, or null outside a request. */
42
+ function currentRequest() {
43
+ try {
44
+ const als = store();
45
+ return (als && als.getStore()) || null;
46
+ } catch (_) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** The visitor's address for the current request ('' outside a request). */
52
+ function currentClientIp() {
53
+ const req = currentRequest();
54
+ if (!req) return '';
55
+ try {
56
+ return require('./resolve-ip').resolveClientIp(req) || '';
57
+ } catch (_) {
58
+ return '';
59
+ }
60
+ }
61
+
62
+ /** The User-Agent header of the current request ('' outside a request). */
63
+ function currentUserAgent() {
64
+ const req = currentRequest();
65
+ try {
66
+ const ua = req && req.headers && req.headers['user-agent'];
67
+ return ua ? String(ua) : '';
68
+ } catch (_) {
69
+ return '';
70
+ }
71
+ }
72
+
73
+ module.exports = { runWithRequest, currentRequest, currentClientIp, currentUserAgent };
package/resolve-ip.js CHANGED
@@ -182,9 +182,18 @@ function resolveSocketIp(socket) {
182
182
  return normalizeIp(raw);
183
183
  }
184
184
 
185
+ /**
186
+ * The visitor's address for the request SecureNow is handling right now ('' outside a request), for code
187
+ * that only gets a Web Request, like a Next.js route handler (8.26.0+). Same trusted-proxy rules as above.
188
+ */
189
+ function currentClientIp() {
190
+ return require('./request-context').currentClientIp();
191
+ }
192
+
185
193
  module.exports = {
186
194
  resolveClientIp,
187
195
  resolveClientIpWithDetails,
196
+ currentClientIp,
188
197
  resolveSocketIp,
189
198
  isFromTrustedProxy,
190
199
  normalizeIp,
package/sessions.js CHANGED
@@ -17,6 +17,7 @@ const crypto = require('crypto');
17
17
  const appConfig = require('./app-config');
18
18
  const events = require('./events');
19
19
  const { resolveClientIp: resolveTrustedClientIp } = require('./resolve-ip');
20
+ const requestContext = require('./request-context');
20
21
 
21
22
  let _entries = new Map(); // "type:value" -> expiryMs (0 = never)
22
23
  let _etag = null;
@@ -217,9 +218,10 @@ let _captureWindowMs = 5 * 60 * 1000;
217
218
 
218
219
  // The client IP the way the firewall and traces resolve it (resolve-ip.js): forwarding headers count only
219
220
  // when the connection comes from a trusted proxy. Trusting X-Forwarded-For from anyone let a client choose the
220
- // IP that session-theft detection compares.
221
+ // IP that session-theft detection compares. A Web Request (Next.js route handler, Hono) has no socket: the
222
+ // address then comes from the Node request SecureNow is handling (request-context.js, 8.26.0+).
221
223
  function resolveClientIp(req) {
222
- return resolveTrustedClientIp(req) || null;
224
+ return resolveTrustedClientIp(req) || requestContext.currentClientIp() || null;
223
225
  }
224
226
 
225
227
  function emitSessionSeen(sessionId, userId, ip) {