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/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
+ };