securenow 8.25.2 → 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 +18 -0
- package/SKILL-API.md +23 -5
- package/cli/challenges.js +1 -1
- package/cli/config.js +18 -0
- package/cli/diagnostics.js +5 -2
- package/cli/init.js +27 -18
- package/cli/monitor.js +90 -13
- package/cli/rateLimits.js +1 -1
- package/cli/security.js +2 -2
- package/cli/telemetry.js +1 -1
- package/events.d.ts +5 -2
- package/events.js +19 -0
- package/firewall.js +16 -5
- package/nextjs.js +12 -10
- package/package.json +3 -2
- package/request-context.js +73 -0
- package/resolve-ip.js +9 -0
- package/sessions.js +4 -2
package/NPM_README.md
CHANGED
|
@@ -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.
|
|
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
|
|
|
@@ -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
|
|
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 ->
|
|
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 |
|
|
@@ -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
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
|
};
|
package/cli/diagnostics.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
254
|
-
const
|
|
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}, ` : ''}
|
|
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: [
|
|
285
|
-
const
|
|
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.
|
|
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
|
-
|
|
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(
|
|
105
|
+
ui.c.dim(String(t.traceID || t.traceId || t._id || '')),
|
|
51
106
|
t.environment || 'production',
|
|
52
|
-
t.
|
|
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.
|
|
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', '
|
|
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
|
|
95
|
-
span
|
|
96
|
-
ui.httpStatusColor(span
|
|
97
|
-
ui.durationColor(span
|
|
98
|
-
span
|
|
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
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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.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 =
|
|
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
|
|
1470
|
-
req
|
|
1471
|
-
|
|
1472
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
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) {
|