securenow 8.11.0 → 8.11.2
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 +3 -1
- package/README.md +2 -0
- package/SKILL-API.md +3 -1
- package/SKILL-CLI.md +3 -1
- package/cli.js +1 -1
- package/firewall-only.js +31 -28
- package/internal-thread.js +22 -0
- package/legacy-semconv.js +4 -3
- package/nextjs-auto-capture.js +11 -5
- package/nextjs-middleware.js +13 -7
- package/nextjs-wrapper.js +11 -5
- package/nextjs.js +19 -12
- package/nuxt-server-plugin.mjs +11 -5
- package/otp-redaction.js +99 -0
- package/package.json +4 -2
- package/register.js +9 -6
- package/tracing.js +859 -851
package/NPM_README.md
CHANGED
|
@@ -1307,7 +1307,7 @@ all runtime settings from this JSON plus built-in defaults. Production should
|
|
|
1307
1307
|
mount a tokenless runtime credentials file at `.securenow/credentials.json`.
|
|
1308
1308
|
Environment-variable fallback is not supported.
|
|
1309
1309
|
|
|
1310
|
-
**Default sensitive fields (auto-redacted):** `password`, `passwd`, `pwd`, `secret`, `token`, `api_key`, `apikey`, `access_token`, `auth`, `credentials`, `mysql_pwd`, `stripeToken`, `card`, `cardnumber`, `ccv`, `cvc`, `cvv`, `ssn`, `pin`
|
|
1310
|
+
**Default sensitive fields (auto-redacted):** `password`, `passwd`, `pwd`, `secret`, `token`, `api_key`, `apikey`, `access_token`, `auth`, `credentials`, `mysql_pwd`, `stripeToken`, `card`, `cardnumber`, `ccv`, `cvc`, `cvv`, `ssn`, `pin`, `code`, `otpCode`, `oneTimeCode`, `verificationCode`, `passcode`
|
|
1311
1311
|
|
|
1312
1312
|
For instrumentation, firewall layers, debugging, trusted proxies, and deployment
|
|
1313
1313
|
environment, edit the matching `config.*` keys in `.securenow/runtime.json`
|
|
@@ -1450,6 +1450,8 @@ File binary content is never stored in traces.
|
|
|
1450
1450
|
|
|
1451
1451
|
### Sensitive Data Redaction
|
|
1452
1452
|
|
|
1453
|
+
SecureNow 8.11.2 redacts sign-in credentials named `code`, `otpCode`, `oneTimeCode`, `verificationCode`, and `passcode` by default, including case and separator variants. JSON, form, GraphQL, and multipart text capture use the same rule; URL query parameters and captured headers are also protected. Credential names match exactly after normalization, so `status_code` and `error_code` remain available for investigation. Redaction changes exported telemetry only.
|
|
1454
|
+
|
|
1453
1455
|
All request bodies are automatically scanned and sensitive fields are redacted:
|
|
1454
1456
|
|
|
1455
1457
|
**Automatically redacted fields:** `password`, `secret`, `token`, `api_key`, `card`, `cvv`, `ssn`, and more.
|
package/README.md
CHANGED
|
@@ -228,6 +228,8 @@ It syncs your app's blocklist plus SecureNow's IP database of 500k+ known-bad IP
|
|
|
228
228
|
|
|
229
229
|
- **SecureNow never needs your source code.** It sees runtime telemetry — requests, spans, logs — not your repository.
|
|
230
230
|
- **Redacted at capture.** `password`, `token`, `api_key`, `authorization`, `cookie`, and friends are auto-redacted before leaving your process; multipart file content is never captured. Preview with `securenow redact '<json>'`.
|
|
231
|
+
|
|
232
|
+
SecureNow 8.11.2 redacts sign-in credentials named `code`, `otpCode`, `oneTimeCode`, `verificationCode`, and `passcode` by default, including case and separator variants. JSON, form, GraphQL, and multipart text capture use the same rule; URL query parameters and captured headers are also protected. Credential names match exactly after normalization, so `status_code` and `error_code` remain available for investigation. Redaction changes exported telemetry only.
|
|
231
233
|
- **Fails open.** If SecureNow is unreachable, your app serves traffic normally (`config.firewall.failMode: "open"` is the default).
|
|
232
234
|
- **Read/write split.** MCP read tools are read-only; write tools require explicit `confirm: true` plus a reason, and human approval is the designed path for mutations — blocks, rate limits, and revocations are proposed, approved, executed, and reversible.
|
|
233
235
|
|
package/SKILL-API.md
CHANGED
|
@@ -542,6 +542,8 @@ securenow cidr parse 10.0.0.0/8 # network/broadcast/
|
|
|
542
542
|
|
|
543
543
|
## Sensitive Data Redaction
|
|
544
544
|
|
|
545
|
+
SecureNow 8.11.2 redacts sign-in credentials named `code`, `otpCode`, `oneTimeCode`, `verificationCode`, and `passcode` by default, including case and separator variants. JSON, form, GraphQL, and multipart text capture use the same rule; URL query parameters and captured headers are also protected. Credential names match exactly after normalization, so `status_code` and `error_code` remain available for investigation. Redaction changes exported telemetry only.
|
|
546
|
+
|
|
545
547
|
Available from multiple entry points (`securenow/nextjs-middleware`, `securenow/nextjs-wrapper`, `securenow/nextjs-auto-capture`):
|
|
546
548
|
|
|
547
549
|
```javascript
|
|
@@ -551,7 +553,7 @@ const safe = redactSensitiveData({ username: 'alice', password: 's3cret', token:
|
|
|
551
553
|
// { username: 'alice', password: '[REDACTED]', token: '[REDACTED]' }
|
|
552
554
|
```
|
|
553
555
|
|
|
554
|
-
**Auto-redacted fields:** `password`, `passwd`, `pwd`, `secret`, `token`, `api_key`, `apikey`, `access_token`, `auth`, `credentials`, `mysql_pwd`, `stripeToken`, `card`, `cardnumber`, `ccv`, `cvc`, `cvv`, `ssn`, `pin`.
|
|
556
|
+
**Auto-redacted fields:** `password`, `passwd`, `pwd`, `secret`, `token`, `api_key`, `apikey`, `access_token`, `auth`, `credentials`, `mysql_pwd`, `stripeToken`, `card`, `cardnumber`, `ccv`, `cvc`, `cvv`, `ssn`, `pin`, `code`, `otpCode`, `oneTimeCode`, `verificationCode`, `passcode`.
|
|
555
557
|
|
|
556
558
|
Add custom fields via `config.capture.sensitiveFields` in `.securenow/runtime.json`.
|
|
557
559
|
|
package/SKILL-CLI.md
CHANGED
|
@@ -538,7 +538,9 @@ securenow test-span "ci.smoke-test" # custom span name
|
|
|
538
538
|
|
|
539
539
|
Both commands use the default SecureNow ingestion gateway plus optional advanced `config.otel.*` overrides from `.securenow/runtime.json` or legacy runtime credentials, and return non-zero on HTTP errors so CI/cron can detect failures.
|
|
540
540
|
|
|
541
|
-
### Utilities — Redaction, CIDR, Diagnostics
|
|
541
|
+
### Utilities — Redaction, CIDR, Diagnostics
|
|
542
|
+
|
|
543
|
+
The `redact` command also hides OTP credentials named `code`, `oneTimeCode`, `verificationCode`, and `passcode` (case/separator variants included), while preserving `status_code` and `error_code`. SDK capture uses the same defaults.
|
|
542
544
|
|
|
543
545
|
These mirror the SDK exports (`redactSensitiveData`, `createMatcher`, `parseCidr`) so agents can validate behavior without writing Node.
|
|
544
546
|
|
package/cli.js
CHANGED
|
@@ -671,7 +671,7 @@ const COMMANDS = {
|
|
|
671
671
|
run: (rawArgs) => require('./cli/run').run(rawArgs),
|
|
672
672
|
},
|
|
673
673
|
redact: {
|
|
674
|
-
desc: 'Redact sensitive fields from a JSON payload',
|
|
674
|
+
desc: 'Redact sensitive fields and OTP codes from a JSON payload',
|
|
675
675
|
usage: "securenow redact '<json>' [--fields f1,f2] [--json]",
|
|
676
676
|
flags: { fields: 'Comma-separated extra field names to redact (added to defaults)' },
|
|
677
677
|
run: (a, f) => require('./cli/utils').redact(a, f),
|
package/firewall-only.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
|
|
4
|
+
if (require('./internal-thread').inInternalThread()) return;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Standalone firewall preload — no OpenTelemetry, no tracing.
|
|
5
8
|
*
|
|
@@ -7,35 +10,35 @@
|
|
|
7
10
|
* node -r securenow/firewall-only app.js
|
|
8
11
|
* NODE_OPTIONS='-r securenow/firewall-only' next start
|
|
9
12
|
*
|
|
10
|
-
* Reads SecureNow runtime credentials from .securenow/*.json. Initialises the
|
|
11
|
-
* HTTP-level firewall when a snk_live_ API key is resolvable.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const appConfig = require('./app-config');
|
|
15
|
-
const firewallOptions = appConfig.resolveFirewallOptions();
|
|
13
|
+
* Reads SecureNow runtime credentials from .securenow/*.json. Initialises the
|
|
14
|
+
* HTTP-level firewall when a snk_live_ API key is resolvable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const appConfig = require('./app-config');
|
|
18
|
+
const firewallOptions = appConfig.resolveFirewallOptions();
|
|
16
19
|
|
|
17
|
-
// Runtime enable/disable is controlled by the dashboard/API toggle. If a
|
|
18
|
-
// firewall key is present, start sync so the remote toggle can be applied.
|
|
19
|
-
if (firewallOptions.apiKey) {
|
|
20
|
-
require('./firewall').init({
|
|
21
|
-
apiKey: firewallOptions.apiKey,
|
|
22
|
-
appKey: firewallOptions.appKey,
|
|
23
|
-
environment: firewallOptions.environment,
|
|
24
|
-
apiUrl: firewallOptions.apiUrl,
|
|
25
|
-
apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
|
|
26
|
-
versionCheckInterval: firewallOptions.versionCheckInterval,
|
|
27
|
-
syncInterval: firewallOptions.syncInterval,
|
|
28
|
-
failMode: firewallOptions.failMode,
|
|
29
|
-
statusCode: firewallOptions.statusCode,
|
|
30
|
-
log: firewallOptions.log,
|
|
31
|
-
tcp: firewallOptions.tcp,
|
|
32
|
-
iptables: firewallOptions.iptables,
|
|
33
|
-
cloud: firewallOptions.cloud,
|
|
34
|
-
cloudDryRun: firewallOptions.cloudDryRun,
|
|
35
|
-
cloudflare: firewallOptions.cloudflare,
|
|
36
|
-
aws: firewallOptions.aws,
|
|
37
|
-
gcp: firewallOptions.gcp,
|
|
38
|
-
});
|
|
20
|
+
// Runtime enable/disable is controlled by the dashboard/API toggle. If a
|
|
21
|
+
// firewall key is present, start sync so the remote toggle can be applied.
|
|
22
|
+
if (firewallOptions.apiKey) {
|
|
23
|
+
require('./firewall').init({
|
|
24
|
+
apiKey: firewallOptions.apiKey,
|
|
25
|
+
appKey: firewallOptions.appKey,
|
|
26
|
+
environment: firewallOptions.environment,
|
|
27
|
+
apiUrl: firewallOptions.apiUrl,
|
|
28
|
+
apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
|
|
29
|
+
versionCheckInterval: firewallOptions.versionCheckInterval,
|
|
30
|
+
syncInterval: firewallOptions.syncInterval,
|
|
31
|
+
failMode: firewallOptions.failMode,
|
|
32
|
+
statusCode: firewallOptions.statusCode,
|
|
33
|
+
log: firewallOptions.log,
|
|
34
|
+
tcp: firewallOptions.tcp,
|
|
35
|
+
iptables: firewallOptions.iptables,
|
|
36
|
+
cloud: firewallOptions.cloud,
|
|
37
|
+
cloudDryRun: firewallOptions.cloudDryRun,
|
|
38
|
+
cloudflare: firewallOptions.cloudflare,
|
|
39
|
+
aws: firewallOptions.aws,
|
|
40
|
+
gcp: firewallOptions.gcp,
|
|
41
|
+
});
|
|
39
42
|
|
|
40
43
|
const shutdown = () => { try { require('./firewall').shutdown(); } catch (_) {} };
|
|
41
44
|
process.on('SIGINT', shutdown);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Node runs --require preloads inside its internal module-customization-hooks
|
|
5
|
+
* thread too (the worker module.register() starts for ESM loader hooks). The
|
|
6
|
+
* SecureNow preloads must only initialize in application threads: in that
|
|
7
|
+
* internal thread they started a second OpenTelemetry SDK and a second firewall
|
|
8
|
+
* poller, and registered the ESM hook twice ("The 'import-in-the-middle' hook
|
|
9
|
+
* has already been initialized").
|
|
10
|
+
*
|
|
11
|
+
* worker_threads.isInternalThread exists on Node >= 22.14 / 23.7. Older Node has
|
|
12
|
+
* no reliable way to tell that thread from a user Worker, so nothing changes there.
|
|
13
|
+
*/
|
|
14
|
+
function inInternalThread() {
|
|
15
|
+
try {
|
|
16
|
+
return require('node:worker_threads').isInternalThread === true;
|
|
17
|
+
} catch (_) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { inInternalThread };
|
package/legacy-semconv.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { isOtpCodeField } = require('./otp-redaction');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Legacy semantic-convention attributes for the OpenTelemetry 0.221+ /
|
|
5
7
|
* contrib 0.74+ instrumentations, which emit ONLY the stable conventions.
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
* - http.client_ip is NOT derived from the first X-Forwarded-For entry (that
|
|
25
27
|
* value is spoofable). SecureNow's requestHook sets http.client_ip from the
|
|
26
28
|
* trusted-proxy resolver (resolve-ip.js).
|
|
27
|
-
* -
|
|
29
|
+
* - OTP/code and signature-style query parameters (same list upstream redacts on
|
|
28
30
|
* url.query / url.full) are replaced with REDACTED in http.target / http.url.
|
|
29
31
|
* Every other byte of the target is kept exactly as received, so detection
|
|
30
32
|
* rules that match encoded payloads keep working.
|
|
@@ -62,7 +64,6 @@ function defined(attributes) {
|
|
|
62
64
|
function redactSearch(search, paramsToRedact = DEFAULT_REDACTED_QUERY_PARAMS) {
|
|
63
65
|
if (typeof search !== 'string' || search.length < 2 || search[0] !== '?') return search;
|
|
64
66
|
const sensitive = new Set(paramsToRedact || []);
|
|
65
|
-
if (sensitive.size === 0) return search;
|
|
66
67
|
let changed = false;
|
|
67
68
|
const parts = search.slice(1).split('&').map((part) => {
|
|
68
69
|
const eq = part.indexOf('=');
|
|
@@ -71,7 +72,7 @@ function redactSearch(search, paramsToRedact = DEFAULT_REDACTED_QUERY_PARAMS) {
|
|
|
71
72
|
try {
|
|
72
73
|
key = decodeURIComponent(rawKey.replace(/\+/g, ' '));
|
|
73
74
|
} catch (_) {}
|
|
74
|
-
if (!sensitive.has(key)) return part;
|
|
75
|
+
if (!sensitive.has(key) && !isOtpCodeField(key)) return part;
|
|
75
76
|
changed = true;
|
|
76
77
|
return `${rawKey}=${REDACTED}`;
|
|
77
78
|
});
|
package/nextjs-auto-capture.js
CHANGED
|
@@ -19,14 +19,18 @@
|
|
|
19
19
|
const { trace } = require('@opentelemetry/api');
|
|
20
20
|
const appConfig = require('./app-config');
|
|
21
21
|
|
|
22
|
+
const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
|
|
23
|
+
|
|
22
24
|
// Default sensitive fields to redact from request bodies.
|
|
23
|
-
//
|
|
25
|
+
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
26
|
+
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
24
27
|
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
25
28
|
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
26
29
|
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
27
30
|
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
28
31
|
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
29
|
-
const DEFAULT_SENSITIVE_FIELDS = [
|
|
32
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
33
|
+
...OTP_SENSITIVE_FIELDS,
|
|
30
34
|
// credentials / auth
|
|
31
35
|
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
32
36
|
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
@@ -84,7 +88,10 @@ function luhnValid(digits) {
|
|
|
84
88
|
*/
|
|
85
89
|
function redactSensitiveValue(value) {
|
|
86
90
|
if (typeof value !== 'string' || value.length === 0) return value;
|
|
87
|
-
|
|
91
|
+
// Credential assignments in GraphQL/query strings need field-aware
|
|
92
|
+
// redaction even though a short numeric OTP has no unique value shape.
|
|
93
|
+
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
94
|
+
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
|
|
88
95
|
let out = value;
|
|
89
96
|
for (const r of VALUE_REDACTORS) {
|
|
90
97
|
r.re.lastIndex = 0;
|
|
@@ -110,9 +117,8 @@ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
110
117
|
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
111
118
|
|
|
112
119
|
for (const key of Object.keys(redacted)) {
|
|
113
|
-
const lowerKey = key.toLowerCase();
|
|
114
120
|
|
|
115
|
-
if (
|
|
121
|
+
if (matchesSensitiveField(key, sensitiveFields)) {
|
|
116
122
|
redacted[key] = '[REDACTED]';
|
|
117
123
|
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
118
124
|
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
package/nextjs-middleware.js
CHANGED
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
|
|
19
19
|
const appConfig = require('./app-config');
|
|
20
20
|
|
|
21
|
+
const { OTP_SENSITIVE_FIELDS, isOtpCodeField, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
|
|
22
|
+
|
|
21
23
|
// Default sensitive fields to redact from request bodies.
|
|
22
|
-
//
|
|
24
|
+
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
25
|
+
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
23
26
|
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
24
27
|
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
25
28
|
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
26
29
|
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
27
30
|
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
28
|
-
const DEFAULT_SENSITIVE_FIELDS = [
|
|
31
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
32
|
+
...OTP_SENSITIVE_FIELDS,
|
|
29
33
|
// credentials / auth
|
|
30
34
|
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
31
35
|
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
@@ -83,7 +87,10 @@ function luhnValid(digits) {
|
|
|
83
87
|
*/
|
|
84
88
|
function redactSensitiveValue(value) {
|
|
85
89
|
if (typeof value !== 'string' || value.length === 0) return value;
|
|
86
|
-
|
|
90
|
+
// Credential assignments in GraphQL/query strings need field-aware
|
|
91
|
+
// redaction even though a short numeric OTP has no unique value shape.
|
|
92
|
+
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
93
|
+
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
|
|
87
94
|
let out = value;
|
|
88
95
|
for (const r of VALUE_REDACTORS) {
|
|
89
96
|
r.re.lastIndex = 0;
|
|
@@ -113,9 +120,8 @@ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
113
120
|
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
114
121
|
|
|
115
122
|
for (const key of Object.keys(redacted)) {
|
|
116
|
-
const lowerKey = key.toLowerCase();
|
|
117
123
|
|
|
118
|
-
if (
|
|
124
|
+
if (matchesSensitiveField(key, sensitiveFields)) {
|
|
119
125
|
redacted[key] = '[REDACTED]';
|
|
120
126
|
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
121
127
|
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
|
@@ -137,7 +143,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
137
143
|
|
|
138
144
|
let redacted = query;
|
|
139
145
|
|
|
140
|
-
sensitiveFields.forEach(field => {
|
|
146
|
+
sensitiveFields.filter(field => field === 'otp' || !isOtpCodeField(field)).forEach(field => {
|
|
141
147
|
const escaped = escapeRegex(field);
|
|
142
148
|
const patterns = [
|
|
143
149
|
new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
|
|
@@ -153,7 +159,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
153
159
|
|
|
154
160
|
// Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
|
|
155
161
|
// body — catches values the key-name pass above can't see.
|
|
156
|
-
redacted = redactSensitiveValue(redacted);
|
|
162
|
+
redacted = redactOtpGraphQL(redactSensitiveValue(redacted));
|
|
157
163
|
|
|
158
164
|
return redacted;
|
|
159
165
|
}
|
package/nextjs-wrapper.js
CHANGED
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
const { trace } = require('@opentelemetry/api');
|
|
19
19
|
const appConfig = require('./app-config');
|
|
20
20
|
|
|
21
|
+
const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
|
|
22
|
+
|
|
21
23
|
// Default sensitive fields to redact from request bodies.
|
|
22
|
-
//
|
|
24
|
+
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
25
|
+
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
23
26
|
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
24
27
|
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
25
28
|
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
26
29
|
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
27
30
|
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
28
|
-
const DEFAULT_SENSITIVE_FIELDS = [
|
|
31
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
32
|
+
...OTP_SENSITIVE_FIELDS,
|
|
29
33
|
// credentials / auth
|
|
30
34
|
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
31
35
|
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
@@ -83,7 +87,10 @@ function luhnValid(digits) {
|
|
|
83
87
|
*/
|
|
84
88
|
function redactSensitiveValue(value) {
|
|
85
89
|
if (typeof value !== 'string' || value.length === 0) return value;
|
|
86
|
-
|
|
90
|
+
// Credential assignments in GraphQL/query strings need field-aware
|
|
91
|
+
// redaction even though a short numeric OTP has no unique value shape.
|
|
92
|
+
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
93
|
+
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
|
|
87
94
|
let out = value;
|
|
88
95
|
for (const r of VALUE_REDACTORS) {
|
|
89
96
|
r.re.lastIndex = 0;
|
|
@@ -109,9 +116,8 @@ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
109
116
|
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
110
117
|
|
|
111
118
|
for (const key of Object.keys(redacted)) {
|
|
112
|
-
const lowerKey = key.toLowerCase();
|
|
113
119
|
|
|
114
|
-
if (
|
|
120
|
+
if (matchesSensitiveField(key, sensitiveFields)) {
|
|
115
121
|
redacted[key] = '[REDACTED]';
|
|
116
122
|
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
117
123
|
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
package/nextjs.js
CHANGED
|
@@ -59,14 +59,18 @@ function createResource(attributes) {
|
|
|
59
59
|
throw new Error('Unsupported @opentelemetry/resources version');
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
const { OTP_SENSITIVE_FIELDS, isOtpCodeField, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery, withOtpRedactionExporter } = require('./otp-redaction');
|
|
63
|
+
|
|
62
64
|
// Default sensitive fields to redact from request bodies.
|
|
63
|
-
//
|
|
65
|
+
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
66
|
+
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
64
67
|
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
65
68
|
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
66
69
|
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
67
70
|
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
68
71
|
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
69
72
|
const DEFAULT_SENSITIVE_FIELDS = [
|
|
73
|
+
...OTP_SENSITIVE_FIELDS,
|
|
70
74
|
// credentials / auth
|
|
71
75
|
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
72
76
|
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
@@ -124,7 +128,10 @@ function luhnValid(digits) {
|
|
|
124
128
|
*/
|
|
125
129
|
function redactSensitiveValue(value) {
|
|
126
130
|
if (typeof value !== 'string' || value.length === 0) return value;
|
|
127
|
-
|
|
131
|
+
// Credential assignments in GraphQL/query strings need field-aware
|
|
132
|
+
// redaction even though a short numeric OTP has no unique value shape.
|
|
133
|
+
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
134
|
+
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
|
|
128
135
|
let out = value;
|
|
129
136
|
for (const r of VALUE_REDACTORS) {
|
|
130
137
|
r.re.lastIndex = 0;
|
|
@@ -150,9 +157,8 @@ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
150
157
|
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
151
158
|
|
|
152
159
|
for (const key of Object.keys(redacted)) {
|
|
153
|
-
const lowerKey = key.toLowerCase();
|
|
154
160
|
|
|
155
|
-
if (
|
|
161
|
+
if (matchesSensitiveField(key, sensitiveFields)) {
|
|
156
162
|
redacted[key] = '[REDACTED]';
|
|
157
163
|
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
158
164
|
// Recursively redact nested objects
|
|
@@ -181,7 +187,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
181
187
|
|
|
182
188
|
// Redact sensitive fields in GraphQL arguments and variables
|
|
183
189
|
// Matches patterns like: password: "value" or password:"value" or password:'value'
|
|
184
|
-
sensitiveFields.forEach(field => {
|
|
190
|
+
sensitiveFields.filter(field => field === 'otp' || !isOtpCodeField(field)).forEach(field => {
|
|
185
191
|
const escaped = escapeRegex(field);
|
|
186
192
|
const patterns = [
|
|
187
193
|
new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
|
|
@@ -201,7 +207,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
201
207
|
|
|
202
208
|
// Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
|
|
203
209
|
// body — catches values the key-name pass above can't see.
|
|
204
|
-
redacted = redactSensitiveValue(redacted);
|
|
210
|
+
redacted = redactOtpGraphQL(redactSensitiveValue(redacted));
|
|
205
211
|
|
|
206
212
|
return redacted;
|
|
207
213
|
}
|
|
@@ -320,7 +326,7 @@ function registerSecureNow(options = {}) {
|
|
|
320
326
|
|
|
321
327
|
// ======== REQUEST METADATA ========
|
|
322
328
|
const userAgent = headers['user-agent'] || '';
|
|
323
|
-
const referer = headers['referer'] || headers['referrer'] || '';
|
|
329
|
+
const referer = redactOtpQuery(headers['referer'] || headers['referrer'] || '');
|
|
324
330
|
const accept = headers['accept'] || '';
|
|
325
331
|
const acceptLanguage = headers['accept-language'] || '';
|
|
326
332
|
const acceptEncoding = headers['accept-encoding'] || '';
|
|
@@ -329,7 +335,7 @@ function registerSecureNow(options = {}) {
|
|
|
329
335
|
const origin = headers['origin'] || '';
|
|
330
336
|
|
|
331
337
|
// ======== PROXY & LOAD BALANCER ========
|
|
332
|
-
const originalUri = headers['x-original-uri'] || '';
|
|
338
|
+
const originalUri = redactOtpQuery(headers['x-original-uri'] || '');
|
|
333
339
|
const originalMethod = headers['x-original-method'] || '';
|
|
334
340
|
const requestId = headers['x-request-id'] || headers['x-trace-id'] || headers['x-correlation-id'] || '';
|
|
335
341
|
|
|
@@ -528,6 +534,7 @@ function registerSecureNow(options = {}) {
|
|
|
528
534
|
const { registerOTel } = require('@vercel/otel');
|
|
529
535
|
|
|
530
536
|
registerOTel({
|
|
537
|
+
traceExporter: withOtpRedactionExporter(new (require('@opentelemetry/exporter-trace-otlp-http').OTLPTraceExporter)({ url: tracesUrl, headers })),
|
|
531
538
|
serviceName: serviceName,
|
|
532
539
|
attributes: {
|
|
533
540
|
'deployment.environment': deploymentEnvironment,
|
|
@@ -558,10 +565,10 @@ function registerSecureNow(options = {}) {
|
|
|
558
565
|
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
|
559
566
|
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
|
|
560
567
|
|
|
561
|
-
const traceExporter = new OTLPTraceExporter({
|
|
568
|
+
const traceExporter = withOtpRedactionExporter(new OTLPTraceExporter({
|
|
562
569
|
url: tracesUrl,
|
|
563
|
-
|
|
564
|
-
});
|
|
570
|
+
headers
|
|
571
|
+
}));
|
|
565
572
|
|
|
566
573
|
const sdk = new NodeSDK({
|
|
567
574
|
...nodeSdkTelemetryOptions,
|
|
@@ -643,7 +650,7 @@ function registerSecureNow(options = {}) {
|
|
|
643
650
|
if (event === 'request' && req && res) {
|
|
644
651
|
const start = Date.now();
|
|
645
652
|
const method = req.method;
|
|
646
|
-
const url = req.url;
|
|
653
|
+
const url = redactOtpQuery(req.url);
|
|
647
654
|
res.on('finish', () => {
|
|
648
655
|
const reqCtx = otelContext.active();
|
|
649
656
|
const reqSpanCtx = otelTrace.getSpanContext(reqCtx);
|
package/nuxt-server-plugin.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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/otp-redaction.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "8.11.2",
|
|
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-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",
|
|
@@ -159,7 +159,9 @@
|
|
|
159
159
|
"firewall.js",
|
|
160
160
|
"challenge.js",
|
|
161
161
|
"otel-defaults.js",
|
|
162
|
+
"internal-thread.js",
|
|
162
163
|
"legacy-semconv.js",
|
|
164
|
+
"otp-redaction.js",
|
|
163
165
|
"rate-limits.js",
|
|
164
166
|
"rate-limits.d.ts",
|
|
165
167
|
"firewall-only.js",
|