securenow 8.15.0 → 8.17.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/NPM_README.md CHANGED
@@ -339,6 +339,11 @@ verification plus SQL validation. See `node_modules/securenow/TELEMETRY-SQL.md`
339
339
  timestamp handling, privacy rules, templates, and the full test-to-production lifecycle.
340
340
 
341
341
  ```bash
342
+ # Describe a detection in English (new in 8.16): SecureNow picks an instant rule (every request,
343
+ # as it arrives) or a periodic one (SQL on a schedule), writes it from your app's real data,
344
+ # checks it against that data, and creates it in test mode when you confirm
345
+ npx securenow alerts rules from-text "a sign-in from a new address, then a password change within 5 minutes" --app <app-key>
346
+
342
347
  # Create a custom detection rule from your own SQL (new in 8.1)
343
348
  npx securenow alerts rules create \
344
349
  --name "Auth: magic-link brute force" \
@@ -347,6 +352,11 @@ npx securenow alerts rules create \
347
352
  --severity high \
348
353
  --nlp "single IP flooding /api/auth/signin or /api/auth/callback"
349
354
 
355
+ # What each run did, run one now, and why a rule didn't alert on an address (new in 8.17)
356
+ npx securenow alerts rules runs <rule-id>
357
+ npx securenow alerts rules run <rule-id>
358
+ npx securenow alerts rules why <rule-id> --ip 203.0.113.50 --since 2h
359
+
350
360
  # View / manage alert rules, channels, and history
351
361
  npx securenow alerts rules
352
362
  npx securenow alerts rules show <rule-id>
@@ -358,7 +368,7 @@ npx securenow alerts channels
358
368
  npx securenow alerts history --limit 20
359
369
  ```
360
370
 
361
- Your detection SQL scopes app keys with the `__USER_APP_KEYS__` placeholder and selects an `ip` column for per-IP aggregation and remediation. SecureNow stores the query, runs it on the rule's schedule (default every 15 min), and routes matches to your in-app channel (override with `--channel <id>`). Requires an API key with `alerts:write`.
371
+ Your detection SQL scopes app keys with the `__USER_APP_KEYS__` placeholder and selects an `ip` column for per-IP aggregation and remediation (SQL without one is refused: it would never notify). SecureNow stores the query, runs it once right away and then on the rule's schedule (default every 15 min), and routes matches to your in-app channel (override with `--channel <id>`). The throttle sends one alert per address every 15 minutes by default; other addresses still alert. Requires an API key with `alerts:write`.
362
372
 
363
373
  ### IP Intelligence & Blocklist
364
374
 
@@ -612,6 +622,8 @@ npx securenow logs --json --level error | jq '.logs'
612
622
  | | `alerts rules` | List rules (status, apps, schedule) |
613
623
  | | `alerts rules show <id>` | Rule detail |
614
624
  | | `alerts rules update <id> --applications-all` / `--apps k1,k2` | Application scope |
625
+ | | `alerts rules runs <id>` / `run <id>` | Run history (outcome of each run) / run a periodic rule now |
626
+ | | `alerts rules why <id> --ip <ip>` | Why the rule did or didn't alert on an address |
615
627
  | | `alerts channels` | Alert channels |
616
628
  | | `alerts history` | Alert history |
617
629
  | **Investigate** | `ip <addr>` | IP intelligence |
package/SKILL-CLI.md CHANGED
@@ -275,6 +275,11 @@ its fields were observed and `alerts rules validate` succeeds. Otherwise label i
275
275
  ```bash
276
276
  securenow alerts # list alert rules (default)
277
277
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
278
+ securenow alerts rules from-text \ # describe a detection in English; checked draft, then create (test mode)
279
+ "one IP failing 10 sign-ins in 5 minutes" \
280
+ --app <key> [--env production] # picks instant or periodic; --instant/--periodic, --draft-only, --yes
281
+ securenow alerts rules create --name "Secrets probe" --apps <key> \
282
+ --conditions '[{"field":"path","operator":"contains","value":"/.env"}]' # an instant rule, no SQL
278
283
  securenow alerts rules validate \ # dry-run a candidate query BEFORE creating any rule
279
284
  --sql @rule.sql --app <key> # no rule id needed — auto-hosts on an existing (system) rule
280
285
  securenow alerts rules create \ # create a custom detection rule from inline SQL
@@ -289,7 +294,10 @@ securenow alerts rules set-sql <id> --sql @new.sql # replace a CUSTOM rule's S
289
294
  securenow alerts rules delete <id> --yes # delete a custom rule (system rules: disable instead)
290
295
  securenow alerts rules update <id> --applications-all # all current & future apps
291
296
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
292
- securenow alerts rules test <id> --mode dry_run --wait # validate an existing rule's query
297
+ securenow alerts rules test <id> --wait # dry-run an existing rule's query (default; --mode live inserts 6 synthetic attack requests)
298
+ securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
299
+ securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
300
+ securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
293
301
  securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait # candidate test against a specific host rule
294
302
  securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
295
303
  securenow alerts rules exclusions <id> list # embedded rule exclusions
@@ -306,7 +314,13 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
306
314
  `resources_string['service.name'] IN (__USER_APP_KEYS__)`, traces
307
315
  (`signoz_traces.distributed_signoz_index_v3`) use `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``.
308
316
 
309
- `alerts rules create` flags: `--name` (required); one of `--sql <sql|@file|->` or `--query-mapping-id <id>`; one of `--apps k1,k2` or `--applications-all`; optional `--description`, `--nlp` (plain-English intent), `--category`, `--severity critical|high|medium|low`, `--schedule <cron>` (default `*/15 * * * *`), `--throttle-minutes N` / `--no-throttle`, `--execution-mode scheduled|instant|hybrid`, `--channel id1,id2` (defaults to your in-app SecureNow channel). Detection SQL must scope app keys via the `__USER_APP_KEYS__` placeholder and select an `ip` column for per-IP aggregation/remediation. Requires `alerts:write`.
317
+ `alerts rules create` flags: `--name` (required); one of `--sql <sql|@file|->` or `--query-mapping-id <id>`; one of `--apps k1,k2` or `--applications-all`; optional `--description`, `--nlp` (plain-English intent), `--category`, `--severity critical|high|medium|low`, `--schedule <cron>` (default `*/15 * * * *`), `--throttle-minutes N` / `--no-throttle`, `--execution-mode scheduled|instant|hybrid`, `--channel id1,id2` (defaults to your in-app SecureNow channel). Detection SQL must scope app keys via the `__USER_APP_KEYS__` placeholder and select an `ip` column for per-IP aggregation/remediation: create and set-sql refuse SQL with no ip column (it would never notify) unless `--allow-no-ip`. A new periodic rule runs once right away. Requires `alerts:write`.
318
+
319
+ **Throttle = one alert per rule AND address** (8.17): an address alerted on within the window (default 15 min) is left out of the next runs' notifications, and other addresses still alert. Alert storms keep one alert per rule per window.
320
+
321
+ **When a rule stays quiet,** don't guess: `alerts rules runs <id>` shows every run's outcome — `notified`, `no_rows`, `throttled`, `suppressed_blocked` (already blocked, often by another rule's instant block), `suppressed_trusted`, `no_ip` (rows without an ip column), `storm`, `auto_resolved` (dismissed by an exclusion, trusted IP or verified bot), `skipped`, `error` — and which addresses were left out and why. `alerts rules why <id> --ip <ip>` ends in a verdict such as "Detected at 15:06:31 but not notified: 203.0.113.50 was already blocked by instant rule X". `--yes` on `from-text` refuses to create a rule whose check matches your data right now (it would alert at once, usually a bad baseline); review the rows, then `--yes --force` if they are a real attack.
322
+
323
+ MCP parity for quiet rules: `securenow_alert_rule_runs` (run history), `securenow_alert_rule_run_now` (write, confirm) and `securenow_alert_rule_explain` (`ip`, `since`: verdict + findings).
310
324
 
311
325
  MCP parity for noisy alert-rule reviews:
312
326
 
package/cli/client.js CHANGED
@@ -56,13 +56,16 @@ function request(method, endpoint, { body, query, token, raw } = {}) {
56
56
  }
57
57
  if (res.statusCode === 403) {
58
58
  const msg = parsed?.error || parsed?.message || 'Access denied';
59
- reject(new CLIError(`${msg}. Admin auth is connected, but this user/plan may lack permission for this control-plane operation. Runtime app connection is unrelated.`, 403));
59
+ const err = new CLIError(`${msg}. Admin auth is connected, but this user/plan may lack permission for this control-plane operation. Runtime app connection is unrelated.`, 403);
60
+ if (parsed?.code) err.code = parsed.code;
61
+ reject(err);
60
62
  return;
61
63
  }
62
64
  if (res.statusCode >= 400) {
63
65
  const msg = parsed?.error || parsed?.message || `Request failed (HTTP ${res.statusCode})`;
64
66
  const details = parsed?.details || parsed?.unauthorizedKeys;
65
67
  const err = new CLIError(details ? `${msg} — ${details}` : msg, res.statusCode);
68
+ if (parsed?.code) err.code = parsed.code;
66
69
  reject(err);
67
70
  return;
68
71
  }
package/cli/monitor.js CHANGED
@@ -342,6 +342,8 @@ async function notificationsList(args, flags) {
342
342
  const s = ui.spinner('Fetching notifications');
343
343
  try {
344
344
  const query = { limit: flags.limit || 20, page: flags.page || 1 };
345
+ if (flags.search) query.search = String(flags.search);
346
+ if (flags.severity) query.severity = String(flags.severity).toLowerCase();
345
347
  const data = await api.get('/notifications', { query });
346
348
  const notifications = data.notifications || [];
347
349
  const pagination = data.pagination;
@@ -354,7 +356,7 @@ async function notificationsList(args, flags) {
354
356
  ui.c.dim(n.id || n._id || ''),
355
357
  ui.statusBadge(n.read ? 'read' : 'unread'),
356
358
  ui.truncate(n.title || n.message || n.type || '', 50),
357
- n.ip || '—',
359
+ n.primaryIp || n.ip || '—',
358
360
  n.severity ? ui.statusBadge(n.severity) : '—',
359
361
  ui.timeAgo(n.createdAt),
360
362
  ]);
package/cli/security.js CHANGED
@@ -33,12 +33,28 @@ function ruleStatusBadge(rule) {
33
33
  return st;
34
34
  }
35
35
 
36
+ function throttleLabel(throttle) {
37
+ return throttle?.enabled ? `one alert per address every ${throttle.minutes || 15} min` : 'off';
38
+ }
39
+
40
+ // The API refuses edits (mode included) on system rules: they are read-only templates.
41
+ function isSystemRuleRefusal(err) {
42
+ return err?.code === 'system_rule_readonly' || (err?.statusCode === 403 && /read-only templates/i.test(err?.message || ''));
43
+ }
44
+
45
+ function printWarnings(data) {
46
+ for (const warning of data?.warnings || []) ui.warn(warning);
47
+ }
48
+
36
49
  /** Dispatch: list | show <id> | update <id> ... */
37
50
  async function alertRulesRoute(args, flags) {
38
51
  const sub = args[0];
39
52
  if (sub === 'create' || sub === 'add') {
40
53
  return alertRuleCreate(args.slice(1), flags);
41
54
  }
55
+ if (sub === 'from-text' || sub === 'draft') {
56
+ return alertRuleFromText(args.slice(1), flags);
57
+ }
42
58
  if (sub === 'show') {
43
59
  return alertRuleShow(args.slice(1), flags);
44
60
  }
@@ -75,6 +91,15 @@ async function alertRulesRoute(args, flags) {
75
91
  if (sub === 'exclusions') {
76
92
  return alertRuleExclusions(args.slice(1), flags);
77
93
  }
94
+ if (sub === 'runs') {
95
+ return alertRuleRuns(args.slice(1), flags);
96
+ }
97
+ if (sub === 'run' || sub === 'run-now') {
98
+ return alertRuleRunNow(args.slice(1), flags);
99
+ }
100
+ if (sub === 'why' || sub === 'explain') {
101
+ return alertRuleWhy(args.slice(1), flags);
102
+ }
78
103
  if (sub === 'promote') {
79
104
  return alertRuleSetMode(args.slice(1), flags, 'prod');
80
105
  }
@@ -112,7 +137,9 @@ async function alertRuleSetMode(args, flags, mode) {
112
137
  s.stop(`${ui.truncate(id, 12)} → ${mode}`);
113
138
  okCount++;
114
139
  } catch (err) {
115
- s.fail(`Failed on ${ui.truncate(id, 12)}: ${err.message}`);
140
+ s.fail(isSystemRuleRefusal(err)
141
+ ? `${ui.truncate(id, 12)} is a system rule (a read-only template): duplicate it first: securenow alerts rules duplicate ${id}`
142
+ : `Failed on ${ui.truncate(id, 12)}: ${err.message}`);
116
143
  failures.push(id);
117
144
  }
118
145
  }
@@ -125,8 +152,8 @@ async function alertRuleSetMode(args, flags, mode) {
125
152
 
126
153
  // Duplicate any rule (including a read-only system template) into a new CUSTOM rule you
127
154
  // own. System rules are view-only examples — fork one with this to enable / edit it. The
128
- // copy defaults to Disabled (opt in with --enable / --status active); SQL, instant.block,
129
- // schedule, scope, and mode are copied from the source.
155
+ // copy defaults to Disabled (opt in with --enable / --status active) and to test mode
156
+ // (--mode prod to arm it); SQL, instant.block, schedule and scope are copied from the source.
130
157
  async function alertRuleDuplicate(args, flags) {
131
158
  requireAuth();
132
159
  const id = args[0];
@@ -221,8 +248,11 @@ async function alertRuleCreate(args, flags) {
221
248
 
222
249
  const queryMappingId = flags['query-mapping-id'] || flags.queryMappingId;
223
250
  const sqlQuery = readSqlArg(flags);
224
- if (!queryMappingId && !sqlQuery) {
225
- ui.error('Provide the detection query: --sql <sql|@file|-> (or reuse one with --query-mapping-id <id>).');
251
+ const instantConditions = flags.conditions ? readJsonArg(flags.conditions, '--conditions') : null;
252
+ if (!queryMappingId && !sqlQuery && !instantConditions) {
253
+ ui.error('Provide the detection query: --sql <sql|@file|-> (or reuse one with --query-mapping-id <id>),');
254
+ ui.error('or an instant rule: --conditions \'[{"field":"path","operator":"contains","value":"/.env"}]\' [--match any|all].');
255
+ ui.info('Or describe it in English: securenow alerts rules from-text "<what to catch>" --app <key>');
226
256
  console.log(ui.c.dim(' Scope app keys in SQL with the __USER_APP_KEYS__ placeholder. The column differs by table:'));
227
257
  console.log(ui.c.dim(" traces: WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__) AND timestamp >= now64(9) - INTERVAL 15 MINUTE"));
228
258
  console.log(ui.c.dim(" logs: WHERE resources_string['service.name'] IN (__USER_APP_KEYS__) AND timestamp >= toUnixTimestamp64Nano(now64() - INTERVAL 15 MINUTE)"));
@@ -239,12 +269,22 @@ async function alertRuleCreate(args, flags) {
239
269
 
240
270
  const body = { name };
241
271
  if (flags.description) body.description = flags.description;
242
- if (queryMappingId) body.queryMappingId = queryMappingId;
272
+ if (instantConditions) {
273
+ // An instant rule: checked against every incoming request, no SQL and no schedule.
274
+ body.executionMode = 'instant';
275
+ body.instant = {
276
+ enabled: true,
277
+ matchMode: flags.match === 'all' ? 'all' : 'any',
278
+ conditions: Array.isArray(instantConditions) ? instantConditions : [instantConditions],
279
+ };
280
+ const env = resolveEnvironment(flags);
281
+ if (env) body.instant.environments = String(env).split(',').map((e) => e.trim()).filter(Boolean);
282
+ } else if (queryMappingId) body.queryMappingId = queryMappingId;
243
283
  else body.sqlQuery = sqlQuery;
244
284
  if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
245
285
  if (flags.category) body.category = flags.category;
246
286
  if (flags.severity) body.severity = String(flags.severity).toLowerCase();
247
- if (flags['execution-mode']) body.executionMode = flags['execution-mode'];
287
+ if (flags['execution-mode'] && !instantConditions) body.executionMode = flags['execution-mode'];
248
288
 
249
289
  if (hasAll) {
250
290
  body.applicationsAll = true;
@@ -268,6 +308,7 @@ async function alertRuleCreate(args, flags) {
268
308
  }
269
309
  if (flags['no-throttle']) body.throttle = { enabled: false, minutes: 0 };
270
310
  else if (flags['throttle-minutes']) body.throttle = { enabled: true, minutes: Number(flags['throttle-minutes']) };
311
+ if (flags['allow-no-ip']) body.allowNoIp = true;
271
312
 
272
313
  const channelStr = flags.channel || flags.channels;
273
314
  if (channelStr) {
@@ -310,10 +351,12 @@ async function alertRuleCreate(args, flags) {
310
351
  ['Severity', r.severity || '-'],
311
352
  ['Applications', r.applicationsAll ? 'all apps' : ((r.applications || []).join(', ') || '-')],
312
353
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '-')],
313
- ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
354
+ ['Throttle', throttleLabel(r.throttle)],
314
355
  ['Query', r.queryMappingId?.name || r.queryMappingId || '-'],
315
356
  ['Validation', flags['no-validate'] ? 'skipped' : 'dry-run clean'],
357
+ ...(data.firstRunQueued ? [['First run', `queued now (see: securenow alerts rules runs ${createdId})`]] : []),
316
358
  ]);
359
+ printWarnings(data);
317
360
  console.log('');
318
361
  ui.success(`Created. View it with: securenow alerts rules show ${createdId}`);
319
362
  } catch (err) {
@@ -353,10 +396,12 @@ async function alertRuleShow(args, flags) {
353
396
  ['System rule', r.isSystem ? 'yes' : 'no'],
354
397
  ['Applications', appLine],
355
398
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '—')],
356
- ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
399
+ ['Throttle', throttleLabel(r.throttle)],
357
400
  ['Query', r.queryMappingId?.name || '—'],
358
401
  ]);
359
402
  console.log('');
403
+ console.log(ui.c.dim(` Runs: securenow alerts rules runs ${r._id || r.id || id} Why no alert: securenow alerts rules why ${r._id || r.id || id} --ip <ip>`));
404
+ console.log('');
360
405
  } catch (err) {
361
406
  s.fail('Failed to fetch alert rule');
362
407
  throw err;
@@ -441,6 +486,10 @@ async function alertRuleUpdate(args, flags) {
441
486
  ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
442
487
  } catch (err) {
443
488
  s.fail('Failed to update alert rule');
489
+ if (isSystemRuleRefusal(err)) {
490
+ ui.info(`System rules are read-only templates. Duplicate it first: securenow alerts rules duplicate ${id}`);
491
+ process.exit(1);
492
+ }
444
493
  throw err;
445
494
  }
446
495
  }
@@ -456,11 +505,13 @@ async function alertRuleSetSql(args, flags) {
456
505
  const body = { sqlQuery };
457
506
  if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
458
507
  if (flags.category) body.category = flags.category;
508
+ if (flags['allow-no-ip']) body.allowNoIp = true;
459
509
 
460
510
  const s = ui.spinner('Updating rule SQL');
461
511
  try {
462
512
  const data = await api.put(`/alert-rules/${id}`, body);
463
513
  s.stop('Rule SQL updated');
514
+ printWarnings(data);
464
515
  const ok = flags['no-validate'] ? null : await validateRuleById(id, flags);
465
516
  if (ok && ok.status === 'failed') {
466
517
  ui.warn(`Saved, but the new SQL failed a dry-run: ${ok.error || 'unknown error'}`);
@@ -646,11 +697,188 @@ function readSqlArg(flags) {
646
697
  return text;
647
698
  }
648
699
 
700
+ // JSON from a flag value, '@file' or '-' (stdin).
701
+ function readJsonArg(raw, label) {
702
+ const text = String(raw);
703
+ const source = text === '-' ? fs.readFileSync(0, 'utf8') : text.startsWith('@') ? fs.readFileSync(text.slice(1), 'utf8') : text;
704
+ try {
705
+ return JSON.parse(source);
706
+ } catch (err) {
707
+ ui.error(`${label} is not valid JSON: ${err.message}`);
708
+ process.exit(1);
709
+ }
710
+ }
711
+
712
+ // ── English → alert rule ──
713
+ // The API drafts the rule from the app's real data (instant or periodic), checks it against that
714
+ // data, and returns the draft. Nothing is created until you confirm; new rules start in test mode.
715
+
716
+ async function waitForDraft(draftId, s) {
717
+ for (let i = 0; i < 90; i++) {
718
+ await new Promise(r => setTimeout(r, 1500));
719
+ const result = await api.get(`/alert-rules/draft/${draftId}`);
720
+ if (result.status !== 'running') return result;
721
+ if (s && i === 6) s.update('Checking the draft against your data');
722
+ }
723
+ return { status: 'failed', error: 'The draft is still running after 2 minutes; try again' };
724
+ }
725
+
726
+ function draftCell(value) {
727
+ if (value == null) return '';
728
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
729
+ return text.length > 40 ? `${text.slice(0, 37)}…` : text;
730
+ }
731
+
732
+ function printDraft(result) {
733
+ const d = result.draft || {};
734
+ const check = result.check || {};
735
+ const kindLabel = d.kind === 'instant' ? ui.c.cyan('Instant rule') : ui.c.cyan('Periodic rule');
736
+ console.log('');
737
+ console.log(` ${kindLabel} ${ui.c.dim('·')} ${d.severity || 'medium'} ${ui.c.dim('·')} ${d.category || 'custom'}`);
738
+ console.log(` ${ui.c.bold(d.name || '')}`);
739
+ if (d.summary) console.log(` ${d.summary}`);
740
+ if (d.whyThisKind) console.log(ui.c.dim(` Why ${d.kind}: ${d.whyThisKind}`));
741
+ console.log('');
742
+ if (d.kind === 'instant') {
743
+ const conditions = d.instant?.conditions || [];
744
+ const envs = d.instant?.environments || [];
745
+ console.log(` Watches every request, as it arrives${envs.length ? `, in ${envs.join(', ')}` : ', in every environment'}`);
746
+ console.log(` When ${d.instant?.matchMode === 'all' ? 'all of' : 'any of'}`);
747
+ for (const c of conditions) console.log(` ${c.field} ${ui.c.dim(c.operator)} ${c.value}`);
748
+ } else {
749
+ const parts = [d.schedule?.description || d.schedule?.cronExpression];
750
+ if (d.windowMinutes) parts.push(`window ${d.windowMinutes} min`);
751
+ if (d.threshold) parts.push(d.threshold);
752
+ console.log(` Schedule ${parts.filter(Boolean).join(ui.c.dim(' · '))}`);
753
+ console.log(' SQL');
754
+ for (const line of String(d.sqlQuery || '').split('\n')) console.log(ui.c.dim(` ${line}`));
755
+ }
756
+ console.log('');
757
+ console.log(` Check ${check.note || ''}`);
758
+ const rows = check.rows || [];
759
+ if (rows.length) {
760
+ const cols = Object.keys(rows[0]).slice(0, 6);
761
+ console.log('');
762
+ ui.table(cols, rows.slice(0, 8).map((row) => cols.map((c) => draftCell(row[c]))));
763
+ }
764
+ for (const w of check.warnings || []) ui.warn(w);
765
+ }
766
+
767
+ function draftToCreateBody(result, flags) {
768
+ const d = result.draft || {};
769
+ const body = {
770
+ name: flags.name ? String(flags.name) : d.name,
771
+ description: d.summary || '',
772
+ nlpQuery: d.nlpQuery,
773
+ category: d.category,
774
+ severity: flags.severity ? String(flags.severity).toLowerCase() : d.severity,
775
+ applicationsAll: false,
776
+ applications: [result.applicationKey],
777
+ executionMode: d.executionMode,
778
+ };
779
+ if (d.executionMode === 'instant') body.instant = d.instant;
780
+ else {
781
+ body.sqlQuery = d.sqlQuery;
782
+ body.schedule = d.schedule;
783
+ }
784
+ if (flags['no-throttle']) body.throttle = { enabled: false, minutes: 0 };
785
+ else if (flags['throttle-minutes']) body.throttle = { enabled: true, minutes: Number(flags['throttle-minutes']) };
786
+ return body;
787
+ }
788
+
789
+ async function alertRuleFromText(args, flags) {
790
+ requireAuth();
791
+ const text = (args.join(' ').trim() || (typeof flags.text === 'string' ? flags.text : '')).trim();
792
+ const appKey = resolveApp(flags);
793
+ if (!text || !appKey) {
794
+ ui.error('Usage: securenow alerts rules from-text "<what the rule should catch>" --app <key> [--env <environment>]');
795
+ console.log(ui.c.dim(' [--instant | --periodic] [--name "..."] [--severity high] [--yes] [--draft-only] [--json]'));
796
+ console.log(ui.c.dim(' Example: securenow alerts rules from-text "one IP failing 10 sign-ins in 5 minutes" --app <key>'));
797
+ process.exit(1);
798
+ }
799
+
800
+ const body = { text, applicationKey: appKey };
801
+ const environment = resolveEnvironment(flags);
802
+ if (environment) body.environment = environment;
803
+ if (flags.instant || flags.kind === 'instant') body.kind = 'instant';
804
+ else if (flags.periodic || flags.kind === 'periodic') body.kind = 'periodic';
805
+
806
+ const s = ui.spinner('Drafting the rule from your app\'s data');
807
+ let result;
808
+ try {
809
+ const started = await api.post('/alert-rules/draft', body);
810
+ result = await waitForDraft(started.draftId, s);
811
+ } catch (err) {
812
+ s.fail('Failed to draft the rule');
813
+ throw err;
814
+ }
815
+ if (result.status !== 'complete') {
816
+ s.fail(result.error || 'Could not draft the rule');
817
+ process.exit(1);
818
+ }
819
+ s.stop('Draft ready');
820
+
821
+ // A draft that matches the data right now alerts the moment it is saved: a real ongoing attack,
822
+ // or (more often) a bad baseline. Unattended (--yes) creation stops there unless --force.
823
+ const matches = Number(result.check?.matches || 0);
824
+ const refuse = Boolean(flags.yes) && !flags.force && !flags['draft-only'] && matches > 0;
825
+ const refusal = `Not created: the check found ${matches} match${matches === 1 ? '' : 'es'} in your data right now, so this rule would alert as soon as it is saved.`;
826
+
827
+ if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
828
+ if (flags.json && refuse) {
829
+ ui.json({ ...result, created: false, refused: 'check_matches', message: refusal });
830
+ process.exit(1);
831
+ }
832
+ printDraft(result);
833
+ console.log('');
834
+ if (flags['draft-only']) {
835
+ ui.info('Draft only: nothing was created. Drop --draft-only to create it.');
836
+ return;
837
+ }
838
+ if (refuse) {
839
+ ui.warn(refusal);
840
+ ui.info('If those rows are a real attack, create it anyway with --yes --force. If they are normal traffic, refine the description and draft again.');
841
+ process.exit(1);
842
+ }
843
+ if (!flags.yes) {
844
+ if (matches > 0) ui.warn(`The check found ${matches} match${matches === 1 ? '' : 'es'} right now (above): this rule alerts as soon as it is saved.`);
845
+ const ok = await ui.confirm('Create this rule? It starts in test mode: it alerts, but blocks nothing');
846
+ if (!ok) { ui.info('Nothing created'); return; }
847
+ }
848
+
849
+ const c = ui.spinner('Creating the rule');
850
+ let data;
851
+ try {
852
+ data = await api.post('/alert-rules', draftToCreateBody(result, flags));
853
+ } catch (err) {
854
+ c.fail('Failed to create the rule');
855
+ throw err;
856
+ }
857
+ const r = data.alertRule || data;
858
+ const id = r._id || r.id || '';
859
+ c.stop('Rule created');
860
+ if (flags.json) { ui.json({ draft: result.draft, check: result.check, alertRule: r }); return; }
861
+ console.log('');
862
+ ui.keyValue([
863
+ ['ID', id || '-'],
864
+ ['Name', r.name || '-'],
865
+ ['Kind', r.executionMode === 'instant' ? 'instant (every request, as it arrives)' : `periodic (${r.schedule?.description || r.schedule?.cronExpression || '-'})`],
866
+ ['Mode', `${r.mode || 'test'} (alerts only, no blocking)`],
867
+ ['Application', (r.applications || []).join(', ') || '-'],
868
+ ...(data.firstRunQueued ? [['First run', 'queued now']] : []),
869
+ ]);
870
+ printWarnings(data);
871
+ console.log('');
872
+ ui.success(`Watching. Promote it when it has proved itself: securenow alerts rules promote ${id}`);
873
+ console.log(ui.c.dim(` Runs: securenow alerts rules runs ${id} Why no alert: securenow alerts rules why ${id} --ip <ip>`));
874
+ }
875
+
649
876
  async function alertRuleTest(args, flags) {
650
877
  requireAuth();
651
878
  const id = args[0];
652
879
  if (!id) {
653
880
  ui.error('Usage: securenow alerts rules test <rule-id> [--app <key>] [--mode dry_run|live] [--wait]');
881
+ console.log(ui.c.dim(' dry_run (default) runs the query on your real data. live inserts 6 synthetic attack requests into your telemetry first.'));
654
882
  process.exit(1);
655
883
  }
656
884
 
@@ -848,8 +1076,170 @@ async function alertRuleExclusions(args, flags) {
848
1076
  }
849
1077
  }
850
1078
 
1079
+ // ── Rule runs: history, run now, why didn't this alert ──
1080
+
1081
+ const RUN_OUTCOME_COLORS = {
1082
+ notified: 'green',
1083
+ storm: 'red',
1084
+ error: 'red',
1085
+ throttled: 'yellow',
1086
+ suppressed_blocked: 'yellow',
1087
+ suppressed_trusted: 'yellow',
1088
+ no_ip: 'yellow',
1089
+ };
1090
+
1091
+ function runOutcomeCell(outcome) {
1092
+ const color = RUN_OUTCOME_COLORS[outcome];
1093
+ return color && ui.c[color] ? ui.c[color](outcome) : ui.c.dim(outcome || '-');
1094
+ }
1095
+
1096
+ function runAddressesCell(run) {
1097
+ const parts = [];
1098
+ if (run.notifiedIps?.length) parts.push(`notified ${run.notifiedIps.slice(0, 2).join(', ')}${run.notifiedIps.length > 2 ? '…' : ''}`);
1099
+ const left = run.suppressedIps || [];
1100
+ if (left.length) {
1101
+ const reasons = [...new Set(left.map((x) => x.reason))].join('/');
1102
+ parts.push(`${left.length} ${reasons}`);
1103
+ }
1104
+ if (!parts.length && run.ips?.length) parts.push(run.ips.slice(0, 2).join(', ') + (run.ips.length > 2 ? '…' : ''));
1105
+ return parts.join('; ') || '—';
1106
+ }
1107
+
1108
+ async function alertRuleRuns(args, flags) {
1109
+ requireAuth();
1110
+ const id = args[0];
1111
+ if (!id) {
1112
+ ui.error('Usage: securenow alerts rules runs <rule-id> [--limit 20] [--since 1h]');
1113
+ process.exit(1);
1114
+ }
1115
+ const query = { limit: flags.limit || 20 };
1116
+ if (flags.since) query.since = flags.since;
1117
+ const s = ui.spinner('Fetching rule runs');
1118
+ let data;
1119
+ try {
1120
+ data = await api.get(`/alert-rules/${id}/runs`, { query });
1121
+ s.stop(`Found ${(data.runs || []).length} run${(data.runs || []).length === 1 ? '' : 's'}`);
1122
+ } catch (err) {
1123
+ s.fail('Failed to fetch rule runs');
1124
+ throw err;
1125
+ }
1126
+ if (flags.json) { ui.json(data); return; }
1127
+
1128
+ const runs = data.runs || [];
1129
+ console.log('');
1130
+ ui.heading(data.ruleName || 'Rule runs');
1131
+ if (data.nextRunAt) console.log(ui.c.dim(` Next scheduled run: ${new Date(data.nextRunAt).toISOString().replace('T', ' ').slice(0, 19)} UTC`));
1132
+ console.log('');
1133
+ if (runs.length === 0) {
1134
+ ui.info(data.executionMode === 'instant'
1135
+ ? 'No matches recorded. An instant rule records a run when a request matches its conditions.'
1136
+ : `No runs recorded. Run it now: securenow alerts rules run ${id}`);
1137
+ return;
1138
+ }
1139
+ ui.table(['Started (UTC)', 'Trigger', 'Outcome', 'Rows', 'Addresses', 'Detail'], runs.map((run) => [
1140
+ new Date(run.startedAt).toISOString().replace('T', ' ').slice(0, 19),
1141
+ run.trigger,
1142
+ runOutcomeCell(run.outcome),
1143
+ String(run.rows ?? 0),
1144
+ ui.truncate(runAddressesCell(run), 44),
1145
+ ui.truncate(run.error || run.detail || (run.notificationId ? `notification ${run.notificationId}` : ''), 60),
1146
+ ]));
1147
+ console.log('');
1148
+ console.log(ui.c.dim(` Why no alert for an address: securenow alerts rules why ${id} --ip <ip> [--since 1h]`));
1149
+ console.log('');
1150
+ }
1151
+
1152
+ async function alertRuleRunNow(args, flags) {
1153
+ requireAuth();
1154
+ const id = args[0];
1155
+ if (!id) {
1156
+ ui.error('Usage: securenow alerts rules run <rule-id> [--no-wait] [--json]');
1157
+ process.exit(1);
1158
+ }
1159
+ const requestedAt = Date.now();
1160
+ const s = ui.spinner('Queueing a run');
1161
+ let queued;
1162
+ try {
1163
+ queued = await api.post(`/alert-rules/${id}/run`, {});
1164
+ } catch (err) {
1165
+ s.fail('Failed to run the rule');
1166
+ throw err;
1167
+ }
1168
+ if (flags['no-wait']) {
1169
+ s.stop(queued.message || 'Queued');
1170
+ if (flags.json) ui.json(queued);
1171
+ return;
1172
+ }
1173
+
1174
+ s.update('Waiting for the run to finish');
1175
+ let run = null;
1176
+ for (let i = 0; i < 30 && !run; i++) {
1177
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1178
+ const data = await api.get(`/alert-rules/${id}/runs`, { query: { limit: 5 } });
1179
+ run = (data.runs || []).find((r) => r.trigger === 'manual' && new Date(r.startedAt).getTime() >= requestedAt - 15000) || null;
1180
+ }
1181
+ if (!run) {
1182
+ s.stop('Queued; the run has not finished yet');
1183
+ ui.info(`Check later: securenow alerts rules runs ${id}`);
1184
+ if (flags.json) ui.json({ ...queued, run: null });
1185
+ return;
1186
+ }
1187
+ s.stop(`Run finished: ${run.outcome}`);
1188
+ if (flags.json) { ui.json({ ...queued, run }); return; }
1189
+ console.log('');
1190
+ ui.keyValue([
1191
+ ['Outcome', runOutcomeCell(run.outcome)],
1192
+ ['Rows', String(run.rows ?? 0)],
1193
+ ['Addresses', String(run.uniqueIps ?? 0)],
1194
+ ['Notified', (run.notifiedIps || []).join(', ') || '—'],
1195
+ ['Left out', (run.suppressedIps || []).map((x) => `${x.ip} (${x.reason})`).join(', ') || '—'],
1196
+ ['Notification', run.notificationId || '—'],
1197
+ ['Duration', `${run.durationMs} ms`],
1198
+ ...(run.detail ? [['Detail', run.detail]] : []),
1199
+ ...(run.error ? [['Error', run.error]] : []),
1200
+ ]);
1201
+ console.log('');
1202
+ }
1203
+
1204
+ async function alertRuleWhy(args, flags) {
1205
+ requireAuth();
1206
+ const id = args[0];
1207
+ const ip = flags.ip || args[1];
1208
+ if (!id || !ip || ip === true) {
1209
+ ui.error('Usage: securenow alerts rules why <rule-id> --ip <ip> [--since 1h|30m|2d|<iso>] [--json]');
1210
+ process.exit(1);
1211
+ }
1212
+ const query = { ip: String(ip), since: flags.since || '1h' };
1213
+ const s = ui.spinner(`Checking what the rule did with ${ip}`);
1214
+ let data;
1215
+ try {
1216
+ data = await api.get(`/alert-rules/${id}/explain`, { query });
1217
+ s.stop('');
1218
+ } catch (err) {
1219
+ s.fail('Failed to explain the rule');
1220
+ throw err;
1221
+ }
1222
+ if (flags.json) { ui.json(data); return; }
1223
+ console.log('');
1224
+ console.log(` ${ui.c.bold(data.verdict || '')}`);
1225
+ console.log('');
1226
+ for (const finding of data.findings || []) {
1227
+ console.log(` • ${finding.title}`);
1228
+ if (finding.detail) console.log(ui.c.dim(` ${finding.detail}`));
1229
+ }
1230
+ console.log('');
1231
+ }
1232
+
851
1233
  // ── Alert Channels ──
852
1234
 
1235
+ function channelDestination(ch) {
1236
+ if (ch.type === 'email') return ch.email || '—';
1237
+ if (ch.type === 'securenow') return 'in-app (SecureNow inbox)';
1238
+ if (ch.type === 'slack') return 'Slack webhook (URL hidden)';
1239
+ if (ch.type === 'webhook') return `${ch.webhookMethod || 'POST'} webhook (URL hidden)`;
1240
+ return '—';
1241
+ }
1242
+
853
1243
  async function alertChannelsList(args, flags) {
854
1244
  requireAuth();
855
1245
  const s = ui.spinner('Fetching alert channels');
@@ -865,10 +1255,10 @@ async function alertChannelsList(args, flags) {
865
1255
  ui.c.dim(ch.id || ch._id || ''),
866
1256
  ch.name || '—',
867
1257
  ch.type || '—',
868
- ui.statusBadge(ch.enabled !== false ? 'enabled' : 'disabled'),
869
- ch.target || ch.email || ch.webhookUrl || '—',
1258
+ ui.statusBadge(ch.status === 'Disabled' ? 'disabled' : 'active'),
1259
+ channelDestination(ch),
870
1260
  ]);
871
- ui.table(['ID', 'Name', 'Type', 'Status', 'Target'], rows);
1261
+ ui.table(['ID', 'Name', 'Type', 'Status', 'Destination'], rows);
872
1262
  console.log('');
873
1263
  } catch (err) {
874
1264
  s.fail('Failed to fetch alert channels');
@@ -883,22 +1273,27 @@ async function alertHistoryList(args, flags) {
883
1273
  const s = ui.spinner('Fetching alert history');
884
1274
  try {
885
1275
  const query = { limit: flags.limit || 20 };
1276
+ if (flags.page) query.page = flags.page;
886
1277
  const data = await api.get('/alert-history', { query });
887
- const history = data.alerts || [];
888
- s.stop(`Found ${history.length} alert${history.length !== 1 ? 's' : ''}${data.totalItems ? ` (${data.totalItems} total)` : ''}`);
1278
+ const history = data.items || data.alerts || [];
1279
+ const total = data.pagination?.total ?? data.totalItems;
1280
+ s.stop(`Found ${history.length} alert${history.length !== 1 ? 's' : ''}${total ? ` (${total} total)` : ''}`);
889
1281
 
890
1282
  if (flags.json) { ui.json(data); return; }
891
1283
 
892
1284
  console.log('');
893
1285
  const rows = history.map(h => [
894
1286
  ui.c.dim(h.id || h._id || ''),
895
- h.ruleName || h.type || '—',
896
- h.severity ? ui.statusBadge(h.severity) : '—',
897
- ui.truncate(h.message || h.description || '', 40),
898
- h.serviceName || '—',
899
- ui.timeAgo(h.triggeredAt || h.createdAt),
1287
+ ui.truncate(h.subject || h.ruleName || '—', 40),
1288
+ `${h.channelType || '—'}${h.channelName ? ` · ${ui.truncate(h.channelName, 20)}` : ''}`,
1289
+ h.status ? ui.statusBadge(h.status) : '—',
1290
+ h.applicationKey ? ui.truncate(h.applicationKey, 12) : '—',
1291
+ ui.timeAgo(h.createdAt),
900
1292
  ]);
901
- ui.table(['ID', 'Rule', 'Severity', 'Message', 'App', 'Triggered'], rows);
1293
+ ui.table(['ID', 'Subject', 'Channel', 'Status', 'App', 'Sent'], rows);
1294
+ if (data.pagination?.totalPages > 1) {
1295
+ console.log(ui.c.dim(` Page ${data.pagination.page} of ${data.pagination.totalPages}: --page <n>`));
1296
+ }
902
1297
  console.log('');
903
1298
  } catch (err) {
904
1299
  s.fail('Failed to fetch alert history');
package/cli.js CHANGED
@@ -240,7 +240,7 @@ const COMMANDS = {
240
240
  desc: 'Manage notifications',
241
241
  usage: 'securenow notifications <subcommand> [options]',
242
242
  sub: {
243
- list: { desc: 'List notifications', flags: { limit: 'Max results', page: 'Page number' }, run: (a, f) => require('./cli/monitor').notificationsList(a, f) },
243
+ list: { desc: 'List notifications', flags: { limit: 'Max results', page: 'Page number', search: 'Only notifications whose title, description or IP contains this text (e.g. a rule name)', severity: 'critical | high | medium | low' }, run: (a, f) => require('./cli/monitor').notificationsList(a, f) },
244
244
  read: { desc: 'Mark notification as read', usage: 'securenow notifications read <id>', run: (a, f) => require('./cli/monitor').notificationsRead(a, f) },
245
245
  'read-all': { desc: 'Mark all as read', run: () => require('./cli/monitor').notificationsReadAll() },
246
246
  unread: { desc: 'Show unread count', run: () => require('./cli/monitor').notificationsUnread() },
@@ -311,19 +311,34 @@ const COMMANDS = {
311
311
  usage: 'securenow alerts <subcommand> [options]',
312
312
  sub: {
313
313
  rules: {
314
- desc: 'Create, list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, or tune alert rules',
315
- usage: 'securenow alerts rules <list|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions> [options]',
314
+ desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, run, explain, or tune alert rules',
315
+ usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|run|runs|why|dry-run-query|tune-query|exclusions|versions|version|rollback> [options]\n'
316
+ + ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]\n'
317
+ + ' securenow alerts rules runs <id> [--limit 20] [--since 1h] what each run saw and why it did or didn\'t notify\n'
318
+ + ' securenow alerts rules run <id> [--no-wait] run a periodic rule now\n'
319
+ + ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address',
316
320
  flags: {
317
321
  json: 'Output as JSON',
318
- name: 'With create: rule name',
322
+ env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
323
+ instant: 'With from-text: ask for an instant rule (every request, as it arrives)',
324
+ periodic: 'With from-text: ask for a periodic rule (SQL on a schedule)',
325
+ 'draft-only': 'With from-text: show the checked draft, create nothing',
326
+ conditions: 'With create: instant-rule conditions as JSON, @file or - ([{"field":"path","operator":"contains","value":"/.env"}])',
327
+ match: 'With create --conditions: any (default) | all',
328
+ name: 'With create/from-text: rule name',
319
329
  description: 'With create: rule description',
320
330
  nlp: 'With create: plain-English intent stored on the query',
321
331
  text: 'Alias for --nlp',
322
332
  category: 'With create: query category (default: custom)',
323
333
  severity: 'With create: critical | high | medium | low',
324
334
  schedule: 'With create: cron expression (default */15 * * * *)',
325
- 'throttle-minutes': 'With create: notification throttle window in minutes',
335
+ 'throttle-minutes': 'With create: one alert per address every N minutes (default 15)',
326
336
  'no-throttle': 'With create: disable throttling',
337
+ 'allow-no-ip': 'With create/set-sql: save SQL that returns no ip column (it will never notify)',
338
+ ip: 'With why: the address to explain',
339
+ since: 'With runs/why: window, e.g. 30m, 1h, 2d or an ISO date (why defaults to 1h)',
340
+ limit: 'With runs: how many runs to show (default 20)',
341
+ 'no-wait': 'With run: queue the run and return without waiting for it',
327
342
  'execution-mode': 'With create: scheduled | instant | hybrid',
328
343
  channel: 'With create: comma-separated alert channel ids (default: in-app)',
329
344
  'query-mapping-id': 'With create: reuse an existing saved query instead of --sql',
@@ -331,7 +346,7 @@ const COMMANDS = {
331
346
  'no-applications-all': 'With update: scope to explicit --apps list',
332
347
  apps: 'Comma-separated app keys (with create/update)',
333
348
  app: 'Application key for rule tests',
334
- mode: 'With test: dry_run | live. With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
349
+ mode: 'With test: dry_run (default) | live (inserts 6 synthetic attack requests). With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
335
350
  status: 'With update: Active | Disabled | Paused. With list: filter by status',
336
351
  enable: 'With update: shortcut for --status Active',
337
352
  disable: 'With update: shortcut for --status Disabled',
@@ -351,7 +366,7 @@ const COMMANDS = {
351
366
  notification: 'Review notification id tied to tuning',
352
367
  note: 'Review note tied to tuning',
353
368
  yes: 'Confirm write prompts',
354
- force: 'Confirm write prompts',
369
+ force: 'Confirm write prompts. With from-text --yes: create even when the check matches data right now',
355
370
  },
356
371
  run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
357
372
  },
package/mcp/catalog.js CHANGED
@@ -1378,6 +1378,71 @@ const TOOLS = [
1378
1378
  ...confirmSchema,
1379
1379
  }, ['text', 'confirm', 'reason']),
1380
1380
  },
1381
+ {
1382
+ name: 'securenow_alert_rule_draft',
1383
+ title: 'Draft an Alert Rule From English',
1384
+ description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. Waits up to 120 seconds.',
1385
+ scope: 'alerts:write',
1386
+ readOnly: false,
1387
+ confirm: true,
1388
+ method: 'POST',
1389
+ endpoint: '/alert-rules/draft',
1390
+ bodyFields: ['text', 'applicationKey', 'environment', 'kind'],
1391
+ poll: {
1392
+ tool: 'securenow_alert_rule_draft_status',
1393
+ idField: 'draftId',
1394
+ pending: ['running'],
1395
+ timeoutMs: 120000,
1396
+ intervalMs: 2000,
1397
+ },
1398
+ inputSchema: objectSchema({
1399
+ text: string('What the rule should catch, in plain English, e.g. "one IP failing 10 sign-ins in 5 minutes" or "anyone requesting /.env or /.git".'),
1400
+ applicationKey: string('App key the rule watches; the draft is checked against its data.'),
1401
+ environment: string('Only watch this environment (e.g. production). Omit to watch every environment.'),
1402
+ kind: string('Optional: instant or periodic. Omit to let the drafter choose.'),
1403
+ ...confirmSchema,
1404
+ }, ['text', 'applicationKey', 'confirm', 'reason']),
1405
+ },
1406
+ {
1407
+ name: 'securenow_alert_rule_draft_status',
1408
+ title: 'Alert Rule Draft Result',
1409
+ description: 'Get an alert rule draft started by securenow_alert_rule_draft: status (running, complete or failed), then the draft (kind, name, summary, SQL and schedule, or instant conditions) and the check against the app\'s data.',
1410
+ scope: 'alerts:read',
1411
+ readOnly: true,
1412
+ method: 'GET',
1413
+ endpoint: '/alert-rules/draft/:draftId',
1414
+ pathParams: ['draftId'],
1415
+ inputSchema: objectSchema({
1416
+ draftId: string('Draft id from securenow_alert_rule_draft.'),
1417
+ }, ['draftId']),
1418
+ },
1419
+ {
1420
+ name: 'securenow_alert_rule_create',
1421
+ title: 'Create Alert Rule (Test Mode)',
1422
+ description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (the API refuses SQL with no ip column, since it would never alert, unless allowNoIp). A periodic rule runs once right away (firstRunQueued); see securenow_alert_rule_runs. Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
1423
+ scope: 'alerts:write',
1424
+ readOnly: false,
1425
+ confirm: true,
1426
+ method: 'POST',
1427
+ endpoint: '/alert-rules',
1428
+ bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle', 'allowNoIp'],
1429
+ inputSchema: objectSchema({
1430
+ name: string('Rule name.'),
1431
+ description: string('What it catches, in a sentence.'),
1432
+ nlpQuery: string('The detection idea in plain English (kept with the rule).'),
1433
+ category: string('One word, e.g. authentication, scanning, exfiltration.'),
1434
+ severity: string('low, medium, high or critical.'),
1435
+ applications: { type: 'array', items: { type: 'string' }, description: 'App keys the rule watches.' },
1436
+ applicationsAll: boolean('true watches every app instead of applications.'),
1437
+ executionMode: string('scheduled (periodic SQL, the default) or instant (conditions on every request).'),
1438
+ sqlQuery: string('Periodic rules: the detection SELECT.'),
1439
+ schedule: { type: 'object', additionalProperties: true, description: 'Periodic rules: {cronExpression: "* * * * *", description: "Every minute"}. Default every 15 minutes.' },
1440
+ instant: { type: 'object', additionalProperties: true, description: 'Instant rules: {matchMode: "any" | "all", conditions: [{field, operator, value}], environments: ["staging"] (optional; omit to watch every environment)}.' },
1441
+ throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per address every N minutes (other addresses still alert).' },
1442
+ allowNoIp: boolean('Periodic rules: save SQL that returns no ip column anyway (it will never notify). Default false.'),
1443
+ ...confirmSchema,
1444
+ }, ['name', 'confirm', 'reason']),
1445
+ },
1381
1446
  {
1382
1447
  name: 'securenow_alert_rules_list',
1383
1448
  title: 'List Alert Rules',
@@ -1472,9 +1537,9 @@ const TOOLS = [
1472
1537
  applicationsAll: boolean('Scope rule to all applications.'),
1473
1538
  applications: arrayOfStrings('Application keys when applicationsAll is false.'),
1474
1539
  schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
1475
- throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch.' },
1540
+ throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch {enabled, minutes}: one alert per address every N minutes.' },
1476
1541
  alertChannelIds: arrayOfStrings('Alert channel ids.'),
1477
- reviewAction: string('Rule review action, e.g. keep_active or saved_new_version.'),
1542
+ reviewAction: string('Rule review action: keep_active (keep it running and stop auto-pausing it) or tuned_version (the rule was tuned; resume it).'),
1478
1543
  reviewNote: string('Review note.'),
1479
1544
  reviewNotificationId: string('Notification id tied to this review.'),
1480
1545
  ...confirmSchema,
@@ -1768,7 +1833,7 @@ const TOOLS = [
1768
1833
  {
1769
1834
  name: 'securenow_alert_rule_test',
1770
1835
  title: 'Test Alert Rule',
1771
- description: 'Start an alert rule test. Pass mode "dry_run" for a read-only query run; "live" (the server default when mode is omitted) inserts synthetic spans into the app\'s telemetry.',
1836
+ description: 'Start an alert rule test. By default (mode "dry_run") it runs the rule\'s query once on the app\'s real data and inserts nothing. Mode "live" first inserts 6 synthetic attack requests into the app\'s telemetry; only ask for it explicitly.',
1772
1837
  scope: 'alerts:write',
1773
1838
  readOnly: false,
1774
1839
  confirm: true,
@@ -1779,7 +1844,7 @@ const TOOLS = [
1779
1844
  inputSchema: objectSchema({
1780
1845
  id: string('Alert rule id.'),
1781
1846
  applicationKey: string('Application key to test.'),
1782
- mode: string('dry_run (read-only, recommended) or live (inserts synthetic spans).'),
1847
+ mode: string('dry_run (default, read-only) or live (inserts 6 synthetic attack requests).'),
1783
1848
  ...confirmSchema,
1784
1849
  }, ['id', 'confirm', 'reason']),
1785
1850
  },
@@ -1816,6 +1881,53 @@ const TOOLS = [
1816
1881
  testId: string('Alert rule test id.'),
1817
1882
  }, ['id', 'testId']),
1818
1883
  },
1884
+ {
1885
+ name: 'securenow_alert_rule_runs',
1886
+ title: 'List Alert Rule Runs',
1887
+ description: 'A rule\'s recent runs, newest first: when it ran (schedule, manual, create or instant trigger), what it returned (rows, addresses), and the outcome: notified, no_rows, throttled (address alerted on within the throttle window), suppressed_blocked (already blocked), suppressed_trusted, no_ip (rows without an address), storm, auto_resolved (dismissed by an exclusion, trusted IP or verified bot), skipped or error. suppressedIps says which addresses were left out and why. Also nextRunAt for periodic rules. Quiet runs are kept 2 days, the rest 14.',
1888
+ scope: 'alerts:read',
1889
+ readOnly: true,
1890
+ method: 'GET',
1891
+ endpoint: '/alert-rules/:id/runs',
1892
+ pathParams: ['id'],
1893
+ queryFields: ['limit', 'since'],
1894
+ inputSchema: objectSchema({
1895
+ id: string('Alert rule id.'),
1896
+ limit: number('How many runs (default 50, max 200).', { minimum: 1, maximum: 200 }),
1897
+ since: string('Only runs since then: 30m, 1h, 2d or an ISO date.'),
1898
+ }, ['id']),
1899
+ },
1900
+ {
1901
+ name: 'securenow_alert_rule_run_now',
1902
+ title: 'Run Alert Rule Now',
1903
+ description: 'Run a periodic (scheduled) rule once now, outside its schedule. It can notify like a scheduled run (test-mode rules alert only). The run appears in securenow_alert_rule_runs within seconds with trigger "manual". Instant rules have nothing to run (they check every request); use securenow_alert_rule_explain instead. Write action; requires confirmation.',
1904
+ scope: 'alerts:write',
1905
+ readOnly: false,
1906
+ confirm: true,
1907
+ method: 'POST',
1908
+ endpoint: '/alert-rules/:id/run',
1909
+ pathParams: ['id'],
1910
+ inputSchema: objectSchema({
1911
+ id: string('Alert rule id (an active periodic rule).'),
1912
+ ...confirmSchema,
1913
+ }, ['id', 'confirm', 'reason']),
1914
+ },
1915
+ {
1916
+ name: 'securenow_alert_rule_explain',
1917
+ title: 'Explain Why a Rule Did or Didn\'t Alert',
1918
+ description: 'Why did (or didn\'t) this rule alert on this address? Checks, in a time window: the rule\'s status, mode, apps and environments; its runs and what each did with the address (notified, throttled, already blocked, trusted, dismissed); the per-address throttle; blocks on the address and the rule that made them; trusted and allowlist entries; notifications; and the address\'s requests, replayed through an instant rule\'s conditions. Returns ordered findings and a one-line verdict, e.g. "Detected at 15:06:31 but not notified: 203.0.113.50 was already blocked by instant rule X".',
1919
+ scope: 'alerts:read',
1920
+ readOnly: true,
1921
+ method: 'GET',
1922
+ endpoint: '/alert-rules/:id/explain',
1923
+ pathParams: ['id'],
1924
+ queryFields: ['ip', 'since'],
1925
+ inputSchema: objectSchema({
1926
+ id: string('Alert rule id.'),
1927
+ ip: string('The address to explain (IPv4 or IPv6).'),
1928
+ since: string('Window start: 30m, 1h (default), 2d or an ISO date; at most 7 days.'),
1929
+ }, ['id', 'ip']),
1930
+ },
1819
1931
  {
1820
1932
  name: 'securenow_alert_rule_exclusions_list',
1821
1933
  title: 'List Alert Rule Exclusions',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.15.0",
3
+ "version": "8.17.0",
4
4
  "mcpName": "ai.securenow/securenow",
5
5
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
6
6
  "type": "commonjs",