securenow 8.10.0 → 8.11.1
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/README.md +142 -374
- package/cli/agent.js +65 -0
- package/cli.js +60 -43
- package/firewall-only.js +31 -28
- package/internal-thread.js +22 -0
- package/legacy-semconv.js +349 -0
- package/mcp/catalog.js +85 -0
- package/nextjs.js +773 -768
- package/nuxt-server-plugin.mjs +510 -506
- package/package.json +191 -186
- package/register.js +9 -6
- package/tracing.js +16 -5
package/cli/agent.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `securenow agent` — the SOC runbook prompts that SecureNow's retired built-in
|
|
4
|
+
// AI Security Agent used to run on a cron. Print them here so customers can run
|
|
5
|
+
// the same work externally with their own harness (Claude, Hermes, OpenClaw, or
|
|
6
|
+
// any MCP-capable agent), e.g.:
|
|
7
|
+
//
|
|
8
|
+
// claude -p "$(securenow agent prompt patrol --confirm-writes)"
|
|
9
|
+
//
|
|
10
|
+
// The same prompts are published over MCP via prompts/list (soc_patrol,
|
|
11
|
+
// work_alert_review, work_human_actions, ...).
|
|
12
|
+
|
|
13
|
+
const ui = require('./ui');
|
|
14
|
+
const { PROMPTS, promptMessages } = require('../mcp/catalog.js');
|
|
15
|
+
|
|
16
|
+
const PROMPT_ALIASES = {
|
|
17
|
+
patrol: 'soc_patrol',
|
|
18
|
+
soc_patrol: 'soc_patrol',
|
|
19
|
+
'alert-review': 'work_alert_review',
|
|
20
|
+
work_alert_review: 'work_alert_review',
|
|
21
|
+
'human-queue': 'work_human_actions',
|
|
22
|
+
human: 'work_human_actions',
|
|
23
|
+
work_human_actions: 'work_human_actions',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function promptText(name, args) {
|
|
27
|
+
const messages = promptMessages(name, args);
|
|
28
|
+
return messages
|
|
29
|
+
.map((message) => message?.content?.text || '')
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join('\n\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function prompts(args, flags = {}) {
|
|
35
|
+
const rows = PROMPTS.map((p) => ({ name: p.name, title: p.title, description: p.description }));
|
|
36
|
+
if (flags.json) return ui.json({ prompts: rows, aliases: PROMPT_ALIASES });
|
|
37
|
+
ui.heading('SecureNow agent runbook prompts');
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
console.log(`- ${row.name}: ${row.description}`);
|
|
40
|
+
}
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log('Print one with: securenow agent prompt <patrol|alert-review|human-queue|full name>');
|
|
43
|
+
console.log('Scheduled example: claude -p "$(securenow agent prompt patrol --confirm-writes)"');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function prompt(args, flags = {}) {
|
|
47
|
+
const ref = String(args[0] || 'patrol').toLowerCase();
|
|
48
|
+
const name = PROMPT_ALIASES[ref] || ref;
|
|
49
|
+
if (!PROMPTS.some((p) => p.name === name)) {
|
|
50
|
+
throw new Error(`Unknown prompt "${args[0]}". Run \`securenow agent prompts\` to list them.`);
|
|
51
|
+
}
|
|
52
|
+
const promptArgs = {
|
|
53
|
+
confirmWrites: Boolean(flags['confirm-writes'] || flags.confirmWrites || flags.yes),
|
|
54
|
+
};
|
|
55
|
+
if (flags.limit) promptArgs.limit = flags.limit;
|
|
56
|
+
if (flags.search) promptArgs.search = flags.search;
|
|
57
|
+
if (flags['max-alert-review-groups']) promptArgs.maxAlertReviewGroups = flags['max-alert-review-groups'];
|
|
58
|
+
if (flags['max-human-rows']) promptArgs.maxHumanRows = flags['max-human-rows'];
|
|
59
|
+
|
|
60
|
+
const text = promptText(name, promptArgs);
|
|
61
|
+
if (flags.json) return ui.json({ name, prompt: text });
|
|
62
|
+
console.log(text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { prompt, prompts };
|
package/cli.js
CHANGED
|
@@ -188,54 +188,54 @@ const COMMANDS = {
|
|
|
188
188
|
},
|
|
189
189
|
defaultSub: 'list',
|
|
190
190
|
},
|
|
191
|
-
logs: {
|
|
191
|
+
logs: {
|
|
192
192
|
desc: 'View application logs',
|
|
193
193
|
usage: 'securenow logs [options]',
|
|
194
194
|
sub: {
|
|
195
195
|
list: { desc: 'List recent logs', flags: { app: 'App key', env: 'Environment (production, staging, preview, local, or all)', environment: 'Alias for --env', limit: 'Max results (default 200)', since: 'Time window (e.g. 30m, 6h, 2d)', minutes: 'Time window in minutes (alias for --since Nm)', level: 'Filter by level (error, warn, info, debug)', start: 'Start time (ISO 8601)', end: 'End time (ISO 8601)' }, run: (a, f) => require('./cli/monitor').logsList(a, f) },
|
|
196
196
|
trace: { desc: 'Show logs for a trace', usage: 'securenow logs trace <traceId>', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env' }, run: (a, f) => require('./cli/monitor').logsTrace(a, f) },
|
|
197
197
|
},
|
|
198
|
-
defaultSub: 'list',
|
|
199
|
-
},
|
|
200
|
-
telemetry: {
|
|
201
|
-
desc: 'Inspect the alert-SQL telemetry contract and fields observed for an app',
|
|
202
|
-
usage: 'securenow telemetry <schema|fields> [options]',
|
|
203
|
-
flags: {
|
|
204
|
-
app: 'With fields: application key (defaults to connected/default app)',
|
|
205
|
-
env: 'With fields: environment or all (default: production)',
|
|
206
|
-
since: 'With fields: observation window such as 30m, 24h, or 7d (maximum 30d)',
|
|
207
|
-
source: 'Schema section or live signal source',
|
|
208
|
-
limit: 'With fields: maximum fields per signal (1-500, default 200)',
|
|
209
|
-
json: 'Machine-readable output',
|
|
210
|
-
},
|
|
211
|
-
sub: {
|
|
212
|
-
schema: {
|
|
213
|
-
desc: 'Print the packaged ClickHouse tables, fields, mappings, and SQL requirements',
|
|
214
|
-
usage: 'securenow telemetry schema [all|logs|traces|events] [--json]',
|
|
215
|
-
flags: {
|
|
216
|
-
source: 'Schema section: all, logs, traces, or events',
|
|
217
|
-
json: 'Output the machine-readable contract as JSON',
|
|
218
|
-
},
|
|
219
|
-
run: (a, f) => require('./cli/telemetry').schema(a, f),
|
|
220
|
-
},
|
|
221
|
-
fields: {
|
|
222
|
-
desc: 'Discover fields and custom event types actually emitted by an app',
|
|
223
|
-
usage: 'securenow telemetry fields [--app <key>] [--env production] [--since 24h] [--source all] [--limit 200] [--json]',
|
|
224
|
-
flags: {
|
|
225
|
-
app: 'Application key (defaults to connected/default app)',
|
|
226
|
-
env: 'Environment, comma-separated environments, or all (default: production)',
|
|
227
|
-
environment: 'Alias for --env',
|
|
228
|
-
since: 'Observation window such as 30m, 24h, or 7d (maximum 30d)',
|
|
229
|
-
source: 'Signal source: all, logs, or traces',
|
|
230
|
-
limit: 'Maximum fields per signal (1-500, default 200)',
|
|
231
|
-
json: 'Output JSON for agents and scripts',
|
|
232
|
-
},
|
|
233
|
-
run: (a, f) => require('./cli/telemetry').fields(a, f),
|
|
234
|
-
},
|
|
235
|
-
},
|
|
236
|
-
defaultSub: 'schema',
|
|
237
|
-
},
|
|
238
|
-
notifications: {
|
|
198
|
+
defaultSub: 'list',
|
|
199
|
+
},
|
|
200
|
+
telemetry: {
|
|
201
|
+
desc: 'Inspect the alert-SQL telemetry contract and fields observed for an app',
|
|
202
|
+
usage: 'securenow telemetry <schema|fields> [options]',
|
|
203
|
+
flags: {
|
|
204
|
+
app: 'With fields: application key (defaults to connected/default app)',
|
|
205
|
+
env: 'With fields: environment or all (default: production)',
|
|
206
|
+
since: 'With fields: observation window such as 30m, 24h, or 7d (maximum 30d)',
|
|
207
|
+
source: 'Schema section or live signal source',
|
|
208
|
+
limit: 'With fields: maximum fields per signal (1-500, default 200)',
|
|
209
|
+
json: 'Machine-readable output',
|
|
210
|
+
},
|
|
211
|
+
sub: {
|
|
212
|
+
schema: {
|
|
213
|
+
desc: 'Print the packaged ClickHouse tables, fields, mappings, and SQL requirements',
|
|
214
|
+
usage: 'securenow telemetry schema [all|logs|traces|events] [--json]',
|
|
215
|
+
flags: {
|
|
216
|
+
source: 'Schema section: all, logs, traces, or events',
|
|
217
|
+
json: 'Output the machine-readable contract as JSON',
|
|
218
|
+
},
|
|
219
|
+
run: (a, f) => require('./cli/telemetry').schema(a, f),
|
|
220
|
+
},
|
|
221
|
+
fields: {
|
|
222
|
+
desc: 'Discover fields and custom event types actually emitted by an app',
|
|
223
|
+
usage: 'securenow telemetry fields [--app <key>] [--env production] [--since 24h] [--source all] [--limit 200] [--json]',
|
|
224
|
+
flags: {
|
|
225
|
+
app: 'Application key (defaults to connected/default app)',
|
|
226
|
+
env: 'Environment, comma-separated environments, or all (default: production)',
|
|
227
|
+
environment: 'Alias for --env',
|
|
228
|
+
since: 'Observation window such as 30m, 24h, or 7d (maximum 30d)',
|
|
229
|
+
source: 'Signal source: all, logs, or traces',
|
|
230
|
+
limit: 'Maximum fields per signal (1-500, default 200)',
|
|
231
|
+
json: 'Output JSON for agents and scripts',
|
|
232
|
+
},
|
|
233
|
+
run: (a, f) => require('./cli/telemetry').fields(a, f),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
defaultSub: 'schema',
|
|
237
|
+
},
|
|
238
|
+
notifications: {
|
|
239
239
|
desc: 'Manage notifications',
|
|
240
240
|
usage: 'securenow notifications <subcommand> [options]',
|
|
241
241
|
sub: {
|
|
@@ -288,6 +288,23 @@ const COMMANDS = {
|
|
|
288
288
|
},
|
|
289
289
|
defaultSub: 'list',
|
|
290
290
|
},
|
|
291
|
+
agent: {
|
|
292
|
+
desc: 'SOC runbook prompts for running the SecureNow agent work with your own harness (Claude, Hermes, OpenClaw, ...)',
|
|
293
|
+
usage: 'securenow agent <prompt|prompts> [patrol|alert-review|human-queue] [options]',
|
|
294
|
+
flags: {
|
|
295
|
+
json: 'Output as JSON',
|
|
296
|
+
'confirm-writes': 'Emit the prompt variant that allows the external agent to execute decisions',
|
|
297
|
+
limit: 'Row/group budget baked into the prompt',
|
|
298
|
+
search: 'Optional search filter baked into the prompt',
|
|
299
|
+
'max-alert-review-groups': 'SOC patrol: max alert-review root-cause groups per pass',
|
|
300
|
+
'max-human-rows': 'SOC patrol: max Requires Human rows per pass',
|
|
301
|
+
},
|
|
302
|
+
sub: {
|
|
303
|
+
prompt: { desc: 'Print one runbook prompt (pipe it into your harness or schedule it on a cron)', usage: 'securenow agent prompt <patrol|alert-review|human-queue> [--confirm-writes]', run: (a, f) => require('./cli/agent').prompt(a, f) },
|
|
304
|
+
prompts: { desc: 'List the available runbook prompts (same catalog as MCP prompts/list)', run: (a, f) => require('./cli/agent').prompts(a, f) },
|
|
305
|
+
},
|
|
306
|
+
defaultSub: 'prompts',
|
|
307
|
+
},
|
|
291
308
|
alerts: {
|
|
292
309
|
desc: 'Manage alerting',
|
|
293
310
|
usage: 'securenow alerts <subcommand> [options]',
|
|
@@ -803,7 +820,7 @@ function showHelp(commandName) {
|
|
|
803
820
|
'Investigate': ['ip', 'forensics'],
|
|
804
821
|
'Firewall': ['firewall'],
|
|
805
822
|
'Remediation': ['automation', 'ratelimit', 'challenge', 'blocklist', 'revoke', 'allowlist', 'trusted'],
|
|
806
|
-
'Telemetry': ['telemetry', 'log', 'event', 'test-span'],
|
|
823
|
+
'Telemetry': ['telemetry', 'log', 'event', 'test-span'],
|
|
807
824
|
'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
|
|
808
825
|
'Settings': ['instances', 'config', 'version'],
|
|
809
826
|
};
|
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 };
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Legacy semantic-convention attributes for the OpenTelemetry 0.221+ /
|
|
5
|
+
* contrib 0.74+ instrumentations, which emit ONLY the stable conventions.
|
|
6
|
+
* OTEL_SEMCONV_STABILITY_OPT_IN no longer brings the old names back.
|
|
7
|
+
*
|
|
8
|
+
* The SecureNow telemetry contract (telemetry-schema.json "automaticTraceFields"),
|
|
9
|
+
* dashboard queries, alert-rule SQL, block/impact reports and false-positive
|
|
10
|
+
* conditions filter on the legacy names. This module restores them next to the
|
|
11
|
+
* stable ones:
|
|
12
|
+
*
|
|
13
|
+
* - HTTP (withLegacyHttpSemconv): http.method, http.target, http.url,
|
|
14
|
+
* http.status_code, http.host, http.scheme, http.user_agent, net.peer.ip, ...
|
|
15
|
+
* via the HttpInstrumentation's public hooks, reproducing what
|
|
16
|
+
* instrumentation-http <= 0.220 emitted by default.
|
|
17
|
+
* - Databases (withLegacyDbSemconvExporter): db.statement, db.system, db.name,
|
|
18
|
+
* db.operation, db.mongodb.collection / db.sql.table, net.peer.name/port,
|
|
19
|
+
* derived at export time from db.query.text, db.system.name, db.namespace,
|
|
20
|
+
* db.operation.name, db.collection.name, server.address/port. The values are
|
|
21
|
+
* the same ones the old instrumentations wrote under both names.
|
|
22
|
+
*
|
|
23
|
+
* Deliberate HTTP differences from <= 0.220:
|
|
24
|
+
* - http.client_ip is NOT derived from the first X-Forwarded-For entry (that
|
|
25
|
+
* value is spoofable). SecureNow's requestHook sets http.client_ip from the
|
|
26
|
+
* trusted-proxy resolver (resolve-ip.js).
|
|
27
|
+
* - Signature-style query parameters (same list upstream now redacts on
|
|
28
|
+
* url.query / url.full) are replaced with REDACTED in http.target / http.url.
|
|
29
|
+
* Every other byte of the target is kept exactly as received, so detection
|
|
30
|
+
* rules that match encoded payloads keep working.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Mirrors DEFAULT_QUERY_STRINGS_TO_REDACT in @opentelemetry/instrumentation-http.
|
|
34
|
+
const DEFAULT_REDACTED_QUERY_PARAMS = Object.freeze([
|
|
35
|
+
'sig',
|
|
36
|
+
'Signature',
|
|
37
|
+
'AWSAccessKeyId',
|
|
38
|
+
'X-Goog-Signature',
|
|
39
|
+
'X-Amz-Signature',
|
|
40
|
+
'X-Amz-Credential',
|
|
41
|
+
'X-Amz-Security-Token',
|
|
42
|
+
]);
|
|
43
|
+
const REDACTED = 'REDACTED';
|
|
44
|
+
|
|
45
|
+
function isServerResponse(value) {
|
|
46
|
+
return !!value && typeof value.setHeader === 'function' && typeof value.writeHead === 'function';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function defined(attributes) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const key of Object.keys(attributes)) {
|
|
52
|
+
if (attributes[key] !== undefined && attributes[key] !== null) out[key] = attributes[key];
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Redact the values of sensitive query parameters in a `?query` string while
|
|
59
|
+
* leaving every other byte untouched (no re-encoding). Returns the input when
|
|
60
|
+
* nothing matched.
|
|
61
|
+
*/
|
|
62
|
+
function redactSearch(search, paramsToRedact = DEFAULT_REDACTED_QUERY_PARAMS) {
|
|
63
|
+
if (typeof search !== 'string' || search.length < 2 || search[0] !== '?') return search;
|
|
64
|
+
const sensitive = new Set(paramsToRedact || []);
|
|
65
|
+
if (sensitive.size === 0) return search;
|
|
66
|
+
let changed = false;
|
|
67
|
+
const parts = search.slice(1).split('&').map((part) => {
|
|
68
|
+
const eq = part.indexOf('=');
|
|
69
|
+
const rawKey = eq === -1 ? part : part.slice(0, eq);
|
|
70
|
+
let key = rawKey;
|
|
71
|
+
try {
|
|
72
|
+
key = decodeURIComponent(rawKey.replace(/\+/g, ' '));
|
|
73
|
+
} catch (_) {}
|
|
74
|
+
if (!sensitive.has(key)) return part;
|
|
75
|
+
changed = true;
|
|
76
|
+
return `${rawKey}=${REDACTED}`;
|
|
77
|
+
});
|
|
78
|
+
return changed ? `?${parts.join('&')}` : search;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function contentLengthAttributes(headers, direction) {
|
|
82
|
+
const raw = headers && headers['content-length'];
|
|
83
|
+
if (raw === undefined) return {};
|
|
84
|
+
const length = parseInt(raw, 10);
|
|
85
|
+
if (Number.isNaN(length)) return {};
|
|
86
|
+
const encoding = headers['content-encoding'];
|
|
87
|
+
const compressed = !!encoding && encoding !== 'identity';
|
|
88
|
+
return compressed
|
|
89
|
+
? { [`http.${direction}_content_length`]: length }
|
|
90
|
+
: { [`http.${direction}_content_length_uncompressed`]: length };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function httpKindAttributes(httpVersion) {
|
|
94
|
+
if (!httpVersion) return {};
|
|
95
|
+
return {
|
|
96
|
+
'http.flavor': httpVersion,
|
|
97
|
+
'net.transport': String(httpVersion).toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- incoming (SERVER spans) ----
|
|
102
|
+
|
|
103
|
+
function incomingRequestAttributes(request, paramsToRedact) {
|
|
104
|
+
const headers = request.headers || {};
|
|
105
|
+
const component = request.socket && request.socket.encrypted ? 'https' : 'http';
|
|
106
|
+
const host = headers.host;
|
|
107
|
+
const hostname = (typeof host === 'string' && host.replace(/^(.*)(:[0-9]{1,5})/, '$1')) || 'localhost';
|
|
108
|
+
const userAgent = headers['user-agent'];
|
|
109
|
+
|
|
110
|
+
let pathname;
|
|
111
|
+
let search = '';
|
|
112
|
+
let fullUrl;
|
|
113
|
+
try {
|
|
114
|
+
if (host) {
|
|
115
|
+
const parsed = new URL(request.url || '/', `${component}://${host}`);
|
|
116
|
+
pathname = parsed.pathname;
|
|
117
|
+
const redacted = redactSearch(parsed.search, paramsToRedact);
|
|
118
|
+
if (redacted !== parsed.search) parsed.search = redacted;
|
|
119
|
+
search = parsed.search;
|
|
120
|
+
fullUrl = parsed.toString();
|
|
121
|
+
} else {
|
|
122
|
+
const parsed = new URL(request.url || '/', `${component}://localhost`);
|
|
123
|
+
pathname = parsed.pathname;
|
|
124
|
+
search = redactSearch(parsed.search, paramsToRedact);
|
|
125
|
+
fullUrl = pathname + search;
|
|
126
|
+
}
|
|
127
|
+
} catch (_) {}
|
|
128
|
+
|
|
129
|
+
const attributes = {
|
|
130
|
+
'http.url': fullUrl,
|
|
131
|
+
'http.host': host,
|
|
132
|
+
'net.host.name': hostname,
|
|
133
|
+
'http.method': request.method,
|
|
134
|
+
'http.scheme': component,
|
|
135
|
+
...contentLengthAttributes(headers, 'request'),
|
|
136
|
+
...httpKindAttributes(request.httpVersion),
|
|
137
|
+
};
|
|
138
|
+
if (pathname) attributes['http.target'] = pathname + search || '/';
|
|
139
|
+
if (userAgent !== undefined) attributes['http.user_agent'] = userAgent;
|
|
140
|
+
return defined(attributes);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function incomingResponseAttributes(request, response) {
|
|
144
|
+
const socket = request && request.socket;
|
|
145
|
+
const attributes = {
|
|
146
|
+
'http.status_code': response.statusCode,
|
|
147
|
+
'http.status_text': String(response.statusMessage || '').toUpperCase(),
|
|
148
|
+
};
|
|
149
|
+
if (socket) {
|
|
150
|
+
attributes['net.host.ip'] = socket.localAddress;
|
|
151
|
+
attributes['net.host.port'] = socket.localPort;
|
|
152
|
+
attributes['net.peer.ip'] = socket.remoteAddress;
|
|
153
|
+
attributes['net.peer.port'] = socket.remotePort;
|
|
154
|
+
}
|
|
155
|
+
return defined(attributes);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- outgoing (CLIENT spans) ----
|
|
159
|
+
|
|
160
|
+
function hostnameAndPort(options) {
|
|
161
|
+
if (options.hostname && options.port) return { hostname: options.hostname, port: options.port };
|
|
162
|
+
const matches = (typeof options.host === 'string' && options.host.match(/^([^:/ ]+)(:\d{1,5})?/)) || null;
|
|
163
|
+
const hostname = options.hostname || (matches === null ? 'localhost' : matches[1]);
|
|
164
|
+
let port = options.port;
|
|
165
|
+
if (!port) {
|
|
166
|
+
if (matches && matches[2]) port = matches[2].substring(1);
|
|
167
|
+
else port = options.protocol === 'https:' ? '443' : '80';
|
|
168
|
+
}
|
|
169
|
+
return { hostname, port };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function absoluteUrl(options, headers, paramsToRedact) {
|
|
173
|
+
const protocol = options.protocol || 'http:';
|
|
174
|
+
const port = String(options.port || '');
|
|
175
|
+
let path = options.path || '/';
|
|
176
|
+
let host = options.host || options.hostname || headers.host || 'localhost';
|
|
177
|
+
if (String(host).indexOf(':') === -1 && port && port !== '80' && port !== '443') host += `:${port}`;
|
|
178
|
+
if (path.includes('?')) {
|
|
179
|
+
// Same WHATWG normalization as instrumentation-http <= 0.220.
|
|
180
|
+
try {
|
|
181
|
+
const parsed = new URL(path, 'http://localhost');
|
|
182
|
+
path = `${parsed.pathname}${redactSearch(parsed.search, paramsToRedact)}`;
|
|
183
|
+
} catch (_) {}
|
|
184
|
+
}
|
|
185
|
+
const auth = options.auth ? `${REDACTED}:${REDACTED}@` : '';
|
|
186
|
+
return `${protocol}//${auth}${host}${path}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function outgoingRequestAttributes(options, paramsToRedact) {
|
|
190
|
+
const headers = options.headers || {};
|
|
191
|
+
const { hostname, port } = hostnameAndPort(options);
|
|
192
|
+
const rawPath = options.path || '/';
|
|
193
|
+
const q = rawPath.indexOf('?');
|
|
194
|
+
const target = q === -1 ? rawPath : rawPath.slice(0, q) + redactSearch(rawPath.slice(q), paramsToRedact);
|
|
195
|
+
const attributes = {
|
|
196
|
+
'http.url': absoluteUrl(options, headers, paramsToRedact),
|
|
197
|
+
'http.method': options.method || 'GET',
|
|
198
|
+
'http.target': target,
|
|
199
|
+
'net.peer.name': hostname,
|
|
200
|
+
'http.host': headers.host !== undefined ? headers.host : `${hostname}:${port}`,
|
|
201
|
+
};
|
|
202
|
+
if (headers['user-agent'] !== undefined) attributes['http.user_agent'] = headers['user-agent'];
|
|
203
|
+
return defined(attributes);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function outgoingResponseAttributes(response) {
|
|
207
|
+
const { statusCode, statusMessage, httpVersion, socket } = response;
|
|
208
|
+
const attributes = {
|
|
209
|
+
...contentLengthAttributes(response.headers || {}, 'response'),
|
|
210
|
+
...httpKindAttributes(httpVersion),
|
|
211
|
+
};
|
|
212
|
+
if (socket) {
|
|
213
|
+
attributes['net.peer.ip'] = socket.remoteAddress;
|
|
214
|
+
attributes['net.peer.port'] = socket.remotePort;
|
|
215
|
+
}
|
|
216
|
+
if (statusCode) {
|
|
217
|
+
attributes['http.status_code'] = statusCode;
|
|
218
|
+
attributes['http.status_text'] = String(statusMessage || '').toUpperCase();
|
|
219
|
+
}
|
|
220
|
+
return defined(attributes);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function safe(fn, fallback) {
|
|
224
|
+
try {
|
|
225
|
+
return fn();
|
|
226
|
+
} catch (_) {
|
|
227
|
+
return fallback;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Returns a copy of an HttpInstrumentation config whose hooks also emit the
|
|
233
|
+
* legacy attributes. Existing hooks are preserved and run after the legacy
|
|
234
|
+
* attributes are set (same ordering as instrumentation-http <= 0.220, so a
|
|
235
|
+
* caller hook can still override a legacy value), except for the final
|
|
236
|
+
* server-side status which, like <= 0.220, is written when the response ends.
|
|
237
|
+
*/
|
|
238
|
+
function withLegacyHttpSemconv(config = {}) {
|
|
239
|
+
const serverParams = config.redactedQueryParamsServer || DEFAULT_REDACTED_QUERY_PARAMS;
|
|
240
|
+
const clientParams = config.redactedQueryParams || DEFAULT_REDACTED_QUERY_PARAMS;
|
|
241
|
+
const {
|
|
242
|
+
startIncomingSpanHook,
|
|
243
|
+
startOutgoingSpanHook,
|
|
244
|
+
responseHook,
|
|
245
|
+
applyCustomAttributesOnSpan,
|
|
246
|
+
} = config;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
...config,
|
|
250
|
+
startIncomingSpanHook(request) {
|
|
251
|
+
const legacy = safe(() => incomingRequestAttributes(request, serverParams), {});
|
|
252
|
+
const custom = typeof startIncomingSpanHook === 'function' ? startIncomingSpanHook(request) : undefined;
|
|
253
|
+
return { ...legacy, ...(custom || {}) };
|
|
254
|
+
},
|
|
255
|
+
startOutgoingSpanHook(options) {
|
|
256
|
+
const legacy = safe(() => outgoingRequestAttributes(options, clientParams), {});
|
|
257
|
+
const custom = typeof startOutgoingSpanHook === 'function' ? startOutgoingSpanHook(options) : undefined;
|
|
258
|
+
return { ...legacy, ...(custom || {}) };
|
|
259
|
+
},
|
|
260
|
+
responseHook(span, response) {
|
|
261
|
+
// CLIENT spans receive the IncomingMessage here once the response
|
|
262
|
+
// arrives. SERVER spans receive the ServerResponse before the handler
|
|
263
|
+
// runs; their legacy status is set in applyCustomAttributesOnSpan.
|
|
264
|
+
if (!isServerResponse(response)) {
|
|
265
|
+
safe(() => span.setAttributes(outgoingResponseAttributes(response)));
|
|
266
|
+
}
|
|
267
|
+
if (typeof responseHook === 'function') responseHook(span, response);
|
|
268
|
+
},
|
|
269
|
+
applyCustomAttributesOnSpan(span, request, response) {
|
|
270
|
+
if (isServerResponse(response)) {
|
|
271
|
+
safe(() => span.setAttributes(incomingResponseAttributes(request, response)));
|
|
272
|
+
}
|
|
273
|
+
if (typeof applyCustomAttributesOnSpan === 'function') applyCustomAttributesOnSpan(span, request, response);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- databases (contrib instrumentations >= 0.74) ----
|
|
279
|
+
|
|
280
|
+
// Stable db.system.name values that differ from the legacy db.system values.
|
|
281
|
+
const LEGACY_DB_SYSTEM = Object.freeze({
|
|
282
|
+
'microsoft.sql_server': 'mssql',
|
|
283
|
+
'oracle.db': 'oracle',
|
|
284
|
+
});
|
|
285
|
+
const SQL_SYSTEMS = new Set(['mssql', 'microsoft.sql_server']);
|
|
286
|
+
// instrumentation-mongoose 0.64/0.66 (securenow 8.9/8.10) wrote db.system="mongoose";
|
|
287
|
+
// 0.68 only reports db.system.name="mongodb". Keep "mongoose" so `db.system = 'mongodb'` queries don't
|
|
288
|
+
// start double-counting each operation (mongoose span + driver span).
|
|
289
|
+
const LEGACY_DB_SYSTEM_BY_SCOPE = Object.freeze({
|
|
290
|
+
'@opentelemetry/instrumentation-mongoose': 'mongoose',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
function legacyDbAttributes(attributes, scopeName) {
|
|
294
|
+
if (!attributes || attributes['db.system.name'] === undefined) return null;
|
|
295
|
+
const system = attributes['db.system.name'];
|
|
296
|
+
const legacy = {};
|
|
297
|
+
const alias = (from, to, map) => {
|
|
298
|
+
if (attributes[from] === undefined || attributes[to] !== undefined) return;
|
|
299
|
+
legacy[to] = map ? map(attributes[from]) : attributes[from];
|
|
300
|
+
};
|
|
301
|
+
alias('db.system.name', 'db.system', (v) => LEGACY_DB_SYSTEM_BY_SCOPE[scopeName] || LEGACY_DB_SYSTEM[v] || v);
|
|
302
|
+
alias('db.query.text', 'db.statement');
|
|
303
|
+
alias('db.namespace', 'db.name');
|
|
304
|
+
alias('db.operation.name', 'db.operation');
|
|
305
|
+
alias('server.address', 'net.peer.name');
|
|
306
|
+
alias('server.port', 'net.peer.port');
|
|
307
|
+
if (system === 'mongodb') alias('db.collection.name', 'db.mongodb.collection');
|
|
308
|
+
else if (SQL_SYSTEMS.has(system)) alias('db.collection.name', 'db.sql.table');
|
|
309
|
+
return Object.keys(legacy).length ? legacy : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Wraps a SpanExporter so database client spans are exported with the legacy
|
|
314
|
+
* db.* aliases as well. The finished span is not mutated: the exporter gets a
|
|
315
|
+
* view that delegates to it and only overrides `attributes`.
|
|
316
|
+
*/
|
|
317
|
+
function withLegacyDbSemconvExporter(exporter) {
|
|
318
|
+
const view = (span) => {
|
|
319
|
+
const legacy = safe(() => legacyDbAttributes(span.attributes, span.instrumentationScope && span.instrumentationScope.name), null);
|
|
320
|
+
if (!legacy) return span;
|
|
321
|
+
return Object.create(span, {
|
|
322
|
+
attributes: { value: { ...span.attributes, ...legacy }, enumerable: true },
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
export(spans, resultCallback) {
|
|
327
|
+
const mapped = safe(() => spans.map(view), spans);
|
|
328
|
+
return exporter.export(mapped, resultCallback);
|
|
329
|
+
},
|
|
330
|
+
shutdown() {
|
|
331
|
+
return exporter.shutdown();
|
|
332
|
+
},
|
|
333
|
+
forceFlush() {
|
|
334
|
+
return typeof exporter.forceFlush === 'function' ? exporter.forceFlush() : Promise.resolve();
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
DEFAULT_REDACTED_QUERY_PARAMS,
|
|
341
|
+
withLegacyHttpSemconv,
|
|
342
|
+
withLegacyDbSemconvExporter,
|
|
343
|
+
legacyDbAttributes,
|
|
344
|
+
redactSearch,
|
|
345
|
+
incomingRequestAttributes,
|
|
346
|
+
incomingResponseAttributes,
|
|
347
|
+
outgoingRequestAttributes,
|
|
348
|
+
outgoingResponseAttributes,
|
|
349
|
+
};
|