securenow 8.10.0 → 8.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  };
@@ -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
+ };
package/mcp/catalog.js CHANGED
@@ -1936,6 +1936,26 @@ const PROMPTS = [
1936
1936
  { name: 'confirmWrites', description: 'Set true only when the user explicitly wants the MCP agent to execute decisions.', required: false },
1937
1937
  ],
1938
1938
  },
1939
+ {
1940
+ name: 'work_alert_review',
1941
+ title: 'Work Alert Review',
1942
+ description: 'Use MCP tools to clear the Alert Review queue from root-cause buckets: tune noisy rules with validated guards, batch-resolve covered rows, and keep real detections alive.',
1943
+ arguments: [
1944
+ { name: 'limit', description: 'Maximum root-cause groups to work this run. Defaults to 10.', required: false },
1945
+ { name: 'search', description: 'Optional search filter over rule, route, IP, or evidence.', required: false },
1946
+ { name: 'confirmWrites', description: 'Set true only when the user explicitly wants the MCP agent to execute decisions.', required: false },
1947
+ ],
1948
+ },
1949
+ {
1950
+ name: 'soc_patrol',
1951
+ title: 'SOC Patrol (Scheduled)',
1952
+ description: 'The recurring SOC pass SecureNow\'s built-in agent used to run on a 5-minute cron. Run it from your own harness (Claude, Hermes, OpenClaw, ...) on a schedule: Alert Review root-cause fixes first, then the Requires Human queue.',
1953
+ arguments: [
1954
+ { name: 'maxAlertReviewGroups', description: 'Maximum alert-review root-cause groups to work this pass. Defaults to 10.', required: false },
1955
+ { name: 'maxHumanRows', description: 'Maximum Requires Human rows to work this pass. Defaults to 10.', required: false },
1956
+ { name: 'confirmWrites', description: 'Set true only when this scheduled run is allowed to execute decisions autonomously.', required: false },
1957
+ ],
1958
+ },
1939
1959
  {
1940
1960
  name: 'cleanup_legacy_pending_blocks',
1941
1961
  title: 'Clean Legacy Pending Blocks',
@@ -2079,6 +2099,71 @@ function promptMessages(name, args = {}) {
2079
2099
  ];
2080
2100
  }
2081
2101
 
2102
+ if (name === 'work_alert_review') {
2103
+ const limit = args.limit || 10;
2104
+ const confirmWrites = args.confirmWrites === true || args.confirmWrites === 'true';
2105
+ return [
2106
+ {
2107
+ role: 'user',
2108
+ content: {
2109
+ type: 'text',
2110
+ text: [
2111
+ 'Work my SecureNow Alert Review queue like a senior detection engineer using the MCP tools.',
2112
+ `Work up to ${limit} root-cause group(s), biggest and most urgent first.${args.search ? ` Search filter: ${args.search}.` : ''}`,
2113
+ 'Start with securenow_alert_review_runs_count, then securenow_alert_review_runs_list to get the root-cause groups. Work buckets before singletons: one upstream rule fix closes many downstream rows.',
2114
+ 'For each group, inspect a prototype row with securenow_alert_review_run_get and preview the sibling rows with securenow_alert_review_group_similar. Pull trace evidence with securenow_traces_show / securenow_logs_for_trace when trace IDs are available.',
2115
+ 'Classify the root cause: (a) benign false-positive pattern -> tune the rule, (b) real detection that fired correctly -> keep the rule alive and close the review rows, (c) mixed or unclear -> leave open with the exact missing proof.',
2116
+ 'For rule tuning, propose a narrow attack-preserving guard: combine at least TWO of path, method, status_code, user_agent. Never guard on attacker-controllable fields alone (request body/header payloads), never use path starts_with "/" or catch-all regex. Validate with securenow_alert_rule_condition_candidate_test and securenow_alert_rule_condition_diff (or a dry run) BEFORE applying; the guard must suppress the benign samples while attack examples still match.',
2117
+ 'Apply validated tuning with securenow_alert_rule_condition_update or securenow_alert_rule_exclusion_add. If the rule is a system/global rule and the tool returns access denied, do not force a customer-specific workaround — record the exact prepared guard in the decision report with outcome=rule_tuning and the missing permission.',
2118
+ 'After a rule fix, close the covered rows with securenow_alert_review_run_apply_group_decision (use previewOnly:true first; set allowMixed only after reviewing every row). For correct detections use securenow_alert_review_run_keep_alive. For one-off rows use securenow_alert_review_run_resolve or securenow_alert_review_run_dismiss with a full decision report.',
2119
+ confirmWrites
2120
+ ? 'The user requested execution. Call write tools with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant.'
2121
+ : 'Do not execute write tools yet. Produce a group-by-group plan with the exact validated guards and MCP write calls for user approval.',
2122
+ 'Never resolve a group as false positive without concrete benign samples AND proof the guard preserves attack detection. When uncertain, keep the rule alive and leave the row open with the missing proof named.',
2123
+ 'End with counts: groups worked, rules tuned (with the exact guards), rows resolved/dismissed/kept alive, rows left open with missing proof, and any permission blockers.',
2124
+ ].join('\n'),
2125
+ },
2126
+ },
2127
+ ];
2128
+ }
2129
+
2130
+ if (name === 'soc_patrol') {
2131
+ const maxAlertReviewGroups = args.maxAlertReviewGroups || 10;
2132
+ const maxHumanRows = args.maxHumanRows || 10;
2133
+ const confirmWrites = args.confirmWrites === true || args.confirmWrites === 'true';
2134
+ return [
2135
+ {
2136
+ role: 'user',
2137
+ content: {
2138
+ type: 'text',
2139
+ text: [
2140
+ 'Run one scheduled SecureNow SOC patrol pass using the MCP tools. This is the recurring pass the built-in SecureNow agent used to run every 5 minutes; you are its external replacement, so be conservative, auditable, and finish in one bounded pass.',
2141
+ '',
2142
+ 'Phase 0 — check for work:',
2143
+ '- Call securenow_alert_review_runs_count and securenow_human_actions_list (page=1, limit=1).',
2144
+ '- If both queues are empty, stop and report "no open SOC work".',
2145
+ '',
2146
+ 'Phase 1 — Alert Review first (root-cause rule fixes are the upstream fix; they auto-resolve downstream human work):',
2147
+ `- Follow the work_alert_review runbook for up to ${maxAlertReviewGroups} root-cause group(s): list groups, inspect prototypes and siblings, validate narrow attack-preserving guards (two or more of path/method/status_code/user_agent) with condition tests/diffs before applying, tune rules, batch-resolve covered rows, keep real detections alive, and leave unclear rows open with the exact missing proof.`,
2148
+ '',
2149
+ 'Phase 2 — Requires Human queue:',
2150
+ `- Follow the work_human_actions runbook for up to ${maxHumanRows} row(s): list the queue most urgent first, read each AI report and trace evidence, then choose exactly one outcome per row (block, rate limit, scoped false positive, rule tuning, case action approve/reject, or an auditable no-write decision report with the missing proof).`,
2151
+ '- Re-check rows against any rule changes from Phase 1 before deciding — a Phase 1 fix often already covers them.',
2152
+ '',
2153
+ 'Safety (this run may be unattended):',
2154
+ confirmWrites
2155
+ ? '- Writes are allowed this pass: call write tools with confirm:true, a precise reason, and a full decisionReport. Never block without high-confidence malicious evidence (riskScore >= 90 or equivalent proof). Never create broad false positives or single-condition exclusions. Prefer an auditable no-write decision report whenever evidence is mixed.'
2156
+ : '- Read-only pass: do NOT call write tools. Produce the exact prepared write calls (with guards and reasons) as a reviewable plan.',
2157
+ '- Never trust an IP globally, never create IP allowlist entries, never suppress on attacker-controllable fields alone.',
2158
+ '- Stay within the row/group budgets; leave the rest for the next scheduled pass.',
2159
+ '',
2160
+ 'End with a compact patrol report: queues checked, groups/rows worked, rules tuned (exact guards), blocks/rate limits/false positives executed or prepared, rows left open with missing proof, and anything that needs a human decision.',
2161
+ ].join('\n'),
2162
+ },
2163
+ },
2164
+ ];
2165
+ }
2166
+
2082
2167
  if (name === 'cleanup_legacy_pending_blocks') {
2083
2168
  const limit = args.limit || 50;
2084
2169
  const environment = args.environment || 'production';