securenow 8.11.1 → 8.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,14 +42,18 @@ function createResource(attributes) {
42
42
  throw new Error('Unsupported @opentelemetry/resources version');
43
43
  }
44
44
 
45
+ const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpQuery, withOtpRedactionExporter, redactOtpGraphQL } = nodeRequire('./otp-redaction');
46
+
45
47
  // Default sensitive fields to redact from request bodies.
46
- // Matched substring-wise against lowercased keys (see redactSensitiveData),
48
+ // OTP/code aliases use exact normalized matching; other fields are matched
49
+ // substring-wise against lowercased keys (see redactSensitiveData),
47
50
  // so e.g. 'card' also catches 'creditCard' and 'account' also catches
48
51
  // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
49
52
  // telemetry value is always safer than a leaked secret. Entries are kept
50
53
  // specific enough to avoid nuking broad benign keys (e.g. we use
51
54
  // 'firstname'/'lastname'/'fullname', never bare 'name').
52
55
  const DEFAULT_SENSITIVE_FIELDS = [
56
+ ...OTP_SENSITIVE_FIELDS,
53
57
  // credentials / auth
54
58
  'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
55
59
  'access_token', 'auth', 'authorization', 'bearer', 'credentials',
@@ -107,7 +111,10 @@ function luhnValid(digits) {
107
111
  */
108
112
  function redactSensitiveValue(value) {
109
113
  if (typeof value !== 'string' || value.length === 0) return value;
110
- if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
114
+ // Credential assignments in GraphQL/query strings need field-aware
115
+ // redaction even though a short numeric OTP has no unique value shape.
116
+ value = redactOtpQuery(redactOtpGraphQL(value));
117
+ if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
111
118
  let out = value;
112
119
  for (const r of VALUE_REDACTORS) {
113
120
  r.re.lastIndex = 0;
@@ -128,8 +135,7 @@ function redactSensitiveData(obj, fields = DEFAULT_SENSITIVE_FIELDS) {
128
135
  if (!obj || typeof obj !== 'object') return obj;
129
136
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
130
137
  for (const key of Object.keys(redacted)) {
131
- const lower = key.toLowerCase();
132
- if (fields.some((f) => lower.includes(f.toLowerCase()))) {
138
+ if (matchesSensitiveField(key, fields)) {
133
139
  redacted[key] = '[REDACTED]';
134
140
  } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
135
141
  redacted[key] = redactSensitiveData(redacted[key], fields);
@@ -299,7 +305,7 @@ export default defineNitroPlugin(async (nitroApp) => {
299
305
  }));
300
306
 
301
307
  // ── Trace exporter + SDK ──
302
- const traceExporter = new OTLPTraceExporter({ url: tracesUrl, headers });
308
+ const traceExporter = withOtpRedactionExporter(new OTLPTraceExporter({ url: tracesUrl, headers }));
303
309
 
304
310
  const sdk = new NodeSDK({
305
311
  ...nodeSdkTelemetryOptions,
package/nuxt.mjs CHANGED
@@ -1,75 +1,75 @@
1
- /**
2
- * SecureNow Nuxt 3 Module
3
- *
4
- * Usage in nuxt.config.ts:
5
- *
6
- * export default defineNuxtConfig({
7
- * modules: ['securenow/nuxt'],
8
- * securenow: { // optional overrides
9
- * serviceName: 'my-nuxt-app',
10
- * },
11
- * });
12
- *
13
- * Environment variables (same as all SecureNow integrations):
1
+ /**
2
+ * SecureNow Nuxt 3 Module
3
+ *
4
+ * Usage in nuxt.config.ts:
5
+ *
6
+ * export default defineNuxtConfig({
7
+ * modules: ['securenow/nuxt'],
8
+ * securenow: { // optional overrides
9
+ * serviceName: 'my-nuxt-app',
10
+ * },
11
+ * });
12
+ *
13
+ * Environment variables (same as all SecureNow integrations):
14
14
  * Configuration is read from .securenow/credentials.json.
15
- */
16
-
17
- import { defineNuxtModule, createResolver, addServerPlugin } from '@nuxt/kit';
18
-
19
- const OTEL_EXTERNALS = [
20
- 'securenow',
21
- '@opentelemetry/api',
22
- '@opentelemetry/api-logs',
23
- '@opentelemetry/sdk-node',
24
- '@opentelemetry/sdk-logs',
25
- '@opentelemetry/auto-instrumentations-node',
26
- '@opentelemetry/instrumentation',
27
- '@opentelemetry/instrumentation-http',
28
- '@opentelemetry/exporter-trace-otlp-http',
29
- '@opentelemetry/exporter-logs-otlp-http',
30
- '@opentelemetry/resources',
31
- '@opentelemetry/semantic-conventions',
32
- ];
33
-
34
- export default defineNuxtModule({
35
- meta: {
36
- name: 'securenow',
37
- configKey: 'securenow',
38
- compatibility: { nuxt: '>=3.0.0' },
39
- },
40
-
41
- defaults: {
42
- serviceName: undefined,
43
- endpoint: undefined,
44
- noUuid: undefined,
45
- captureBody: undefined,
46
- logging: undefined,
47
- },
48
-
49
- setup(options, nuxt) {
50
- const { resolve } = createResolver(import.meta.url);
51
-
52
- // ── Externalize OTel packages so Nitro doesn't bundle them ──
53
- nuxt.hook('nitro:config', (nitroConfig) => {
54
- nitroConfig.externals = nitroConfig.externals || {};
55
- nitroConfig.externals.external = [
56
- ...(nitroConfig.externals.external || []),
57
- ...OTEL_EXTERNALS,
58
- ];
59
- });
60
-
61
- // ── Pass module options to the server plugin via runtimeConfig ──
62
- nuxt.options.runtimeConfig.securenow = {
63
- serviceName: options.serviceName,
64
- endpoint: options.endpoint,
65
- noUuid: options.noUuid,
66
- captureBody: options.captureBody,
67
- logging: options.logging,
68
- };
69
-
70
- // ── Register Nitro server plugin ──
71
- addServerPlugin(resolve('./nuxt-server-plugin'));
72
-
73
- console.log('[securenow] Nuxt module loaded — server plugin registered');
74
- },
75
- });
15
+ */
16
+
17
+ import { defineNuxtModule, createResolver, addServerPlugin } from '@nuxt/kit';
18
+
19
+ const OTEL_EXTERNALS = [
20
+ 'securenow',
21
+ '@opentelemetry/api',
22
+ '@opentelemetry/api-logs',
23
+ '@opentelemetry/sdk-node',
24
+ '@opentelemetry/sdk-logs',
25
+ '@opentelemetry/auto-instrumentations-node',
26
+ '@opentelemetry/instrumentation',
27
+ '@opentelemetry/instrumentation-http',
28
+ '@opentelemetry/exporter-trace-otlp-http',
29
+ '@opentelemetry/exporter-logs-otlp-http',
30
+ '@opentelemetry/resources',
31
+ '@opentelemetry/semantic-conventions',
32
+ ];
33
+
34
+ export default defineNuxtModule({
35
+ meta: {
36
+ name: 'securenow',
37
+ configKey: 'securenow',
38
+ compatibility: { nuxt: '>=3.0.0' },
39
+ },
40
+
41
+ defaults: {
42
+ serviceName: undefined,
43
+ endpoint: undefined,
44
+ noUuid: undefined,
45
+ captureBody: undefined,
46
+ logging: undefined,
47
+ },
48
+
49
+ setup(options, nuxt) {
50
+ const { resolve } = createResolver(import.meta.url);
51
+
52
+ // ── Externalize OTel packages so Nitro doesn't bundle them ──
53
+ nuxt.hook('nitro:config', (nitroConfig) => {
54
+ nitroConfig.externals = nitroConfig.externals || {};
55
+ nitroConfig.externals.external = [
56
+ ...(nitroConfig.externals.external || []),
57
+ ...OTEL_EXTERNALS,
58
+ ];
59
+ });
60
+
61
+ // ── Pass module options to the server plugin via runtimeConfig ──
62
+ nuxt.options.runtimeConfig.securenow = {
63
+ serviceName: options.serviceName,
64
+ endpoint: options.endpoint,
65
+ noUuid: options.noUuid,
66
+ captureBody: options.captureBody,
67
+ logging: options.logging,
68
+ };
69
+
70
+ // ── Register Nitro server plugin ──
71
+ addServerPlugin(resolve('./nuxt-server-plugin'));
72
+
73
+ console.log('[securenow] Nuxt module loaded — server plugin registered');
74
+ },
75
+ });
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // Match credential names exactly after case/separator normalization. Treating
4
+ // "code" as a substring would also hide status_code/error_code forensic data.
5
+ const OTP_SENSITIVE_FIELDS = Object.freeze([
6
+ 'code', 'otp', 'otpCode', 'oneTimeCode', 'oneTimePassword', 'passcode',
7
+ 'verificationCode', 'confirmationCode', 'emailCode', 'smsCode', 'loginCode',
8
+ 'signInCode', 'authCode', 'authenticationCode', 'mfaCode', 'totpCode', '2faCode',
9
+ 'securityCode', 'recoveryCode', 'backupCode',
10
+ ]);
11
+ const canonical = (key) => String(key).replace(/[^a-z0-9]/gi, '').toLowerCase();
12
+ const OTP_NAMES = new Set(OTP_SENSITIVE_FIELDS.map(canonical));
13
+
14
+ function isOtpCodeField(key) {
15
+ let name = String(key);
16
+ // Support individually captured header attributes and nested form keys.
17
+ name = name.replace(/^http\.(?:request|response)\.header[s]?\./i, '');
18
+ const nested = name.match(/(?:\[|\.)([^\[\].]+)\]?$/);
19
+ if (nested) name = nested[1];
20
+ name = name.replace(/^x[-_]/i, '');
21
+ return OTP_NAMES.has(canonical(name));
22
+ }
23
+
24
+ function matchesSensitiveField(key, fields) {
25
+ if (isOtpCodeField(key)) return true;
26
+ const lower = String(key).toLowerCase();
27
+ return fields.some((field) => (canonical(field) === 'otp' || !isOtpCodeField(field)) && lower.includes(String(field).toLowerCase()));
28
+ }
29
+
30
+ function redactOtpData(value) {
31
+ if (typeof value === 'string') return redactOtpQuery(redactOtpGraphQL(value));
32
+ if (!value || typeof value !== 'object') return value;
33
+ const out = Array.isArray(value) ? [...value] : { ...value };
34
+ for (const key of Object.keys(out)) {
35
+ out[key] = isOtpCodeField(key) ? '[REDACTED]' : redactOtpData(out[key]);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function redactOtpGraphQL(query) {
41
+ return query.replace(/\b([A-Za-z_][\w]*)(\s*:\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,})]+)/g,
42
+ (match, key, separator) => isOtpCodeField(key) ? `${key}${separator}"[REDACTED]"` : match);
43
+ }
44
+
45
+ // Preserve the exact bytes of harmless query parameters, including attack
46
+ // payloads. Also covers duplicate and percent-encoded credential keys.
47
+ function redactOtpQuery(value, marker = 'REDACTED') {
48
+ if (typeof value !== 'string' || !value.includes('?')) return value;
49
+ return value.replace(/([?&])([^?&#=\s]+)=([^&#\s]*)/g, (part, separator, rawKey) => {
50
+ let key = rawKey;
51
+ try { key = decodeURIComponent(rawKey.replace(/\+/g, ' ')); } catch (_) {}
52
+ return isOtpCodeField(key) ? `${separator}${rawKey}=${marker}` : part;
53
+ });
54
+ }
55
+
56
+ function redactOtpAttributes(attributes = {}) {
57
+ const out = { ...attributes };
58
+ for (const [key, value] of Object.entries(out)) {
59
+ if (isOtpCodeField(key)) {
60
+ out[key] = '[REDACTED]';
61
+ } else if (typeof value === 'string') {
62
+ if (/^http\.(request|response)\.(body|headers|multipart\.fields)$/.test(key)) {
63
+ try {
64
+ // Scrub each string value before serializing. Running URL matching on
65
+ // the serialized JSON would consume closing quotes after query values.
66
+ out[key] = JSON.stringify(redactOtpData(JSON.parse(value)));
67
+ continue;
68
+ } catch (_) {}
69
+ }
70
+ out[key] = redactOtpQuery(out[key]);
71
+ // Stable URL semconv stores the query without a leading question mark.
72
+ if (key === 'url.query') out[key] = redactOtpQuery(`?${value}`).slice(1);
73
+ } else if (Array.isArray(value) && /^http\.(request|response)\.header\./.test(key)) {
74
+ out[key] = value.map((item) => redactOtpQuery(item));
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ // Export a view rather than mutating the completed span or the real request.
81
+ // The final pass catches Next.js/Nitro/fetch attributes written after HTTP hooks.
82
+ function withOtpRedactionExporter(exporter) {
83
+ return {
84
+ export(spans, callback) {
85
+ return exporter.export(spans.map((span) => Object.create(span, {
86
+ attributes: { value: redactOtpAttributes(span.attributes), enumerable: true },
87
+ name: { value: redactOtpQuery(span.name), enumerable: true },
88
+ events: { value: (span.events || []).map((event) => ({ ...event, attributes: redactOtpAttributes(event.attributes) })), enumerable: true },
89
+ })), callback);
90
+ },
91
+ shutdown: () => exporter.shutdown(),
92
+ forceFlush: () => typeof exporter.forceFlush === 'function' ? exporter.forceFlush() : Promise.resolve(),
93
+ };
94
+ }
95
+
96
+ module.exports = {
97
+ OTP_SENSITIVE_FIELDS, isOtpCodeField, matchesSensitiveField, redactOtpData,
98
+ redactOtpGraphQL, redactOtpQuery, redactOtpAttributes, withOtpRedactionExporter,
99
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.11.1",
3
+ "version": "8.11.3",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",
@@ -10,7 +10,7 @@
10
10
  "securenow-mcp": "mcp/server.js"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-internal-thread.js && node test-package-contents.js"
13
+ "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-internal-thread.js && node test-package-contents.js"
14
14
  },
15
15
  "keywords": [
16
16
  "opentelemetry",
@@ -161,6 +161,7 @@
161
161
  "otel-defaults.js",
162
162
  "internal-thread.js",
163
163
  "legacy-semconv.js",
164
+ "otp-redaction.js",
164
165
  "rate-limits.js",
165
166
  "rate-limits.d.ts",
166
167
  "firewall-only.js",
package/register.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- /**
2
- * SecureNow Register Module TypeScript Declarations
3
- *
4
- * Main entry point for Node.js/Express/Fastify/NestJS applications.
5
- * This module is automatically loaded when using NODE_OPTIONS="-r securenow/register"
6
- */
7
-
8
- /**
1
+ /**
2
+ * SecureNow Register Module TypeScript Declarations
3
+ *
4
+ * Main entry point for Node.js/Express/Fastify/NestJS applications.
5
+ * This module is automatically loaded when using NODE_OPTIONS="-r securenow/register"
6
+ */
7
+
8
+ /**
9
9
  * Configuration:
10
10
  *
11
11
  * Local development reads .securenow/credentials.json. Run
@@ -17,28 +17,28 @@
17
17
  * generated JSON to .securenow/credentials.json in the running app.
18
18
  *
19
19
  * Legacy env vars are fallback-only for existing installs.
20
- */
21
-
22
- // This module has side effects (initializes OpenTelemetry)
23
- // It's typically used via NODE_OPTIONS, not imported directly
24
- export {};
25
-
26
- /**
27
- * @example
28
- * ```bash
29
- * # Using with Node.js preload
30
- * NODE_OPTIONS="-r securenow/register" node app.js
31
- * ```
32
- *
33
- * @example
34
- * ```bash
35
- * # Using with PM2
36
- * pm2 start app.js --node-args="-r securenow/register"
37
- * ```
38
- *
39
- * @example
40
- * ```javascript
41
- * // ecosystem.config.js
20
+ */
21
+
22
+ // This module has side effects (initializes OpenTelemetry)
23
+ // It's typically used via NODE_OPTIONS, not imported directly
24
+ export {};
25
+
26
+ /**
27
+ * @example
28
+ * ```bash
29
+ * # Using with Node.js preload
30
+ * NODE_OPTIONS="-r securenow/register" node app.js
31
+ * ```
32
+ *
33
+ * @example
34
+ * ```bash
35
+ * # Using with PM2
36
+ * pm2 start app.js --node-args="-r securenow/register"
37
+ * ```
38
+ *
39
+ * @example
40
+ * ```javascript
41
+ * // ecosystem.config.js
42
42
  * module.exports = {
43
43
  * apps: [{
44
44
  * name: 'my-app',
@@ -47,15 +47,15 @@ export {};
47
47
  * // Local and production use .securenow/credentials.json.
48
48
  * }]
49
49
  * };
50
- * ```
51
- *
52
- * @example
53
- * ```typescript
54
- * // Explicit import (optional, mainly for side effects)
55
- * import 'securenow/register';
56
- *
57
- * import express from 'express';
58
- * const app = express();
59
- * // Your app code...
60
- * ```
61
- */
50
+ * ```
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * // Explicit import (optional, mainly for side effects)
55
+ * import 'securenow/register';
56
+ *
57
+ * import express from 'express';
58
+ * const app = express();
59
+ * // Your app code...
60
+ * ```
61
+ */
package/register.js CHANGED
@@ -1,44 +1,44 @@
1
- // securenow/register.js — the only preload customers need:
2
- // node --require securenow/register app.js
3
- //
4
- // For ESM apps ("type": "module"), this file auto-registers the
5
- // OpenTelemetry ESM loader hook via module.register() (Node >=20.6).
6
- // On older Node versions it falls back to a warning.
7
- 'use strict';
8
-
9
- // Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
10
- if (require('./internal-thread').inInternalThread()) return;
11
-
12
- // 1. Auto-register the ESM loader hook so customers never need --import
13
- (() => {
14
- try {
15
- const fs = require('fs');
16
- const path = require('path');
17
- const pkgPath = path.resolve(process.cwd(), 'package.json');
18
- if (!fs.existsSync(pkgPath)) return;
19
-
20
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^\uFEFF/, ''));
21
- if (pkg.type !== 'module') return;
22
-
23
- // Already registered via --import?
24
- const execArgv = process.execArgv.join(' ');
25
- if (execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle')) return;
26
-
27
- // Node >=20.6 exposes module.register() for programmatic ESM hooks
28
- const mod = require('node:module');
29
- if (typeof mod.register !== 'function') {
30
- console.warn('[securenow] ESM app detected but Node %s lacks module.register().', process.version);
31
- console.warn('[securenow] Upgrade to Node >=20.6 or add: --import @opentelemetry/instrumentation/hook.mjs');
32
- return;
33
- }
34
-
35
- const { pathToFileURL } = require('node:url');
36
- mod.register('@opentelemetry/instrumentation/hook.mjs', pathToFileURL(__filename));
37
- console.log('[securenow] ESM loader hook auto-registered (module.register)');
38
- } catch (_) {
39
- // Non-fatal — tracing.js will show its own ESM warning if the hook is missing
40
- }
41
- })();
42
-
43
- // 2. Run the OTel SDK setup
44
- require('./tracing');
1
+ // securenow/register.js — the only preload customers need:
2
+ // node --require securenow/register app.js
3
+ //
4
+ // For ESM apps ("type": "module"), this file auto-registers the
5
+ // OpenTelemetry ESM loader hook via module.register() (Node >=20.6).
6
+ // On older Node versions it falls back to a warning.
7
+ 'use strict';
8
+
9
+ // Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
10
+ if (require('./internal-thread').inInternalThread()) return;
11
+
12
+ // 1. Auto-register the ESM loader hook so customers never need --import
13
+ (() => {
14
+ try {
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const pkgPath = path.resolve(process.cwd(), 'package.json');
18
+ if (!fs.existsSync(pkgPath)) return;
19
+
20
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^\uFEFF/, ''));
21
+ if (pkg.type !== 'module') return;
22
+
23
+ // Already registered via --import?
24
+ const execArgv = process.execArgv.join(' ');
25
+ if (execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle')) return;
26
+
27
+ // Node >=20.6 exposes module.register() for programmatic ESM hooks
28
+ const mod = require('node:module');
29
+ if (typeof mod.register !== 'function') {
30
+ console.warn('[securenow] ESM app detected but Node %s lacks module.register().', process.version);
31
+ console.warn('[securenow] Upgrade to Node >=20.6 or add: --import @opentelemetry/instrumentation/hook.mjs');
32
+ return;
33
+ }
34
+
35
+ const { pathToFileURL } = require('node:url');
36
+ mod.register('@opentelemetry/instrumentation/hook.mjs', pathToFileURL(__filename));
37
+ console.log('[securenow] ESM loader hook auto-registered (module.register)');
38
+ } catch (_) {
39
+ // Non-fatal — tracing.js will show its own ESM warning if the hook is missing
40
+ }
41
+ })();
42
+
43
+ // 2. Run the OTel SDK setup
44
+ require('./tracing');
package/resolve-ip.js CHANGED
@@ -1,5 +1,5 @@
1
- 'use strict';
2
-
1
+ 'use strict';
2
+
3
3
  const os = require('os');
4
4
  const appConfig = require('./app-config');
5
5
 
@@ -121,20 +121,20 @@ function resolveClientIpWithDetails(request) {
121
121
 
122
122
  return details;
123
123
  }
124
-
125
- /**
126
- * Resolve the real client IP from an HTTP request, respecting trusted proxies.
127
- * Reads X-Forwarded-For / X-Real-IP only when the direct connection comes
128
- * from a private/trusted proxy IP. Prevents client-side IP spoofing.
129
- */
124
+
125
+ /**
126
+ * Resolve the real client IP from an HTTP request, respecting trusted proxies.
127
+ * Reads X-Forwarded-For / X-Real-IP only when the direct connection comes
128
+ * from a private/trusted proxy IP. Prevents client-side IP spoofing.
129
+ */
130
130
  function resolveClientIp(request) {
131
131
  return resolveClientIpWithDetails(request).ip || '';
132
132
  }
133
-
134
- /**
135
- * Resolve IP from a raw TCP socket (no HTTP headers available).
136
- * Normalizes IPv6-mapped IPv4 addresses.
137
- */
133
+
134
+ /**
135
+ * Resolve IP from a raw TCP socket (no HTTP headers available).
136
+ * Normalizes IPv6-mapped IPv4 addresses.
137
+ */
138
138
  function resolveSocketIp(socket) {
139
139
  const raw = socket?.remoteAddress || '';
140
140
  return normalizeIp(raw);
@@ -111,12 +111,14 @@
111
111
  "userId": "attributes_string['enduser.id']",
112
112
  "sessionId": "attributes_string['session.id']",
113
113
  "ip": "attributes_string['http.client_ip']",
114
- "userAgent": "attributes_string['http.user_agent']",
114
+ "userAgent": "attributes_string['user_agent.original']",
115
115
  "severity": "severity_text",
116
116
  "attributes.string": "attributes_string['<custom-key>']",
117
117
  "attributes.number": "attributes_number['<custom-key>']",
118
118
  "attributes.boolean": "attributes_bool['<custom-key>']"
119
119
  },
120
+ "reservedAttributePrefixes": ["event.", "enduser.", "session.", "http.", "service.", "securenow.", "client."],
121
+ "reservedAttributeNote": "Custom attribute keys with a reserved prefix are stored as attr.<key>. Events keep the sender-supplied timestamp and carry no event ID. client.asn / client.as_org are added from ip only when the instance has ASN enrichment.",
120
122
  "recommendedAuthenticationEvents": [
121
123
  "auth.login.failure",
122
124
  "auth.login.success",