securenow 8.16.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
@@ -352,6 +352,11 @@ npx securenow alerts rules create \
352
352
  --severity high \
353
353
  --nlp "single IP flooding /api/auth/signin or /api/auth/callback"
354
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
+
355
360
  # View / manage alert rules, channels, and history
356
361
  npx securenow alerts rules
357
362
  npx securenow alerts rules show <rule-id>
@@ -363,7 +368,7 @@ npx securenow alerts channels
363
368
  npx securenow alerts history --limit 20
364
369
  ```
365
370
 
366
- 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`.
367
372
 
368
373
  ### IP Intelligence & Blocklist
369
374
 
@@ -617,6 +622,8 @@ npx securenow logs --json --level error | jq '.logs'
617
622
  | | `alerts rules` | List rules (status, apps, schedule) |
618
623
  | | `alerts rules show <id>` | Rule detail |
619
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 |
620
627
  | | `alerts channels` | Alert channels |
621
628
  | | `alerts history` | Alert history |
622
629
  | **Investigate** | `ip <addr>` | IP intelligence |
package/SKILL-CLI.md CHANGED
@@ -294,7 +294,10 @@ securenow alerts rules set-sql <id> --sql @new.sql # replace a CUSTOM rule's S
294
294
  securenow alerts rules delete <id> --yes # delete a custom rule (system rules: disable instead)
295
295
  securenow alerts rules update <id> --applications-all # all current & future apps
296
296
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
297
- 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
298
301
  securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait # candidate test against a specific host rule
299
302
  securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
300
303
  securenow alerts rules exclusions <id> list # embedded rule exclusions
@@ -311,7 +314,13 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
311
314
  `resources_string['service.name'] IN (__USER_APP_KEYS__)`, traces
312
315
  (`signoz_traces.distributed_signoz_index_v3`) use `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``.
313
316
 
314
- `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).
315
324
 
316
325
  MCP parity for noisy alert-rule reviews:
317
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/security.js CHANGED
@@ -33,6 +33,19 @@ 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];
@@ -78,6 +91,15 @@ async function alertRulesRoute(args, flags) {
78
91
  if (sub === 'exclusions') {
79
92
  return alertRuleExclusions(args.slice(1), flags);
80
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
+ }
81
103
  if (sub === 'promote') {
82
104
  return alertRuleSetMode(args.slice(1), flags, 'prod');
83
105
  }
@@ -115,7 +137,9 @@ async function alertRuleSetMode(args, flags, mode) {
115
137
  s.stop(`${ui.truncate(id, 12)} → ${mode}`);
116
138
  okCount++;
117
139
  } catch (err) {
118
- 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}`);
119
143
  failures.push(id);
120
144
  }
121
145
  }
@@ -284,6 +308,7 @@ async function alertRuleCreate(args, flags) {
284
308
  }
285
309
  if (flags['no-throttle']) body.throttle = { enabled: false, minutes: 0 };
286
310
  else if (flags['throttle-minutes']) body.throttle = { enabled: true, minutes: Number(flags['throttle-minutes']) };
311
+ if (flags['allow-no-ip']) body.allowNoIp = true;
287
312
 
288
313
  const channelStr = flags.channel || flags.channels;
289
314
  if (channelStr) {
@@ -326,10 +351,12 @@ async function alertRuleCreate(args, flags) {
326
351
  ['Severity', r.severity || '-'],
327
352
  ['Applications', r.applicationsAll ? 'all apps' : ((r.applications || []).join(', ') || '-')],
328
353
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '-')],
329
- ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
354
+ ['Throttle', throttleLabel(r.throttle)],
330
355
  ['Query', r.queryMappingId?.name || r.queryMappingId || '-'],
331
356
  ['Validation', flags['no-validate'] ? 'skipped' : 'dry-run clean'],
357
+ ...(data.firstRunQueued ? [['First run', `queued now (see: securenow alerts rules runs ${createdId})`]] : []),
332
358
  ]);
359
+ printWarnings(data);
333
360
  console.log('');
334
361
  ui.success(`Created. View it with: securenow alerts rules show ${createdId}`);
335
362
  } catch (err) {
@@ -369,10 +396,12 @@ async function alertRuleShow(args, flags) {
369
396
  ['System rule', r.isSystem ? 'yes' : 'no'],
370
397
  ['Applications', appLine],
371
398
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '—')],
372
- ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
399
+ ['Throttle', throttleLabel(r.throttle)],
373
400
  ['Query', r.queryMappingId?.name || '—'],
374
401
  ]);
375
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('');
376
405
  } catch (err) {
377
406
  s.fail('Failed to fetch alert rule');
378
407
  throw err;
@@ -457,6 +486,10 @@ async function alertRuleUpdate(args, flags) {
457
486
  ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
458
487
  } catch (err) {
459
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
+ }
460
493
  throw err;
461
494
  }
462
495
  }
@@ -472,11 +505,13 @@ async function alertRuleSetSql(args, flags) {
472
505
  const body = { sqlQuery };
473
506
  if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
474
507
  if (flags.category) body.category = flags.category;
508
+ if (flags['allow-no-ip']) body.allowNoIp = true;
475
509
 
476
510
  const s = ui.spinner('Updating rule SQL');
477
511
  try {
478
512
  const data = await api.put(`/alert-rules/${id}`, body);
479
513
  s.stop('Rule SQL updated');
514
+ printWarnings(data);
480
515
  const ok = flags['no-validate'] ? null : await validateRuleById(id, flags);
481
516
  if (ok && ok.status === 'failed') {
482
517
  ui.warn(`Saved, but the new SQL failed a dry-run: ${ok.error || 'unknown error'}`);
@@ -783,14 +818,30 @@ async function alertRuleFromText(args, flags) {
783
818
  }
784
819
  s.stop('Draft ready');
785
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
+
786
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
+ }
787
832
  printDraft(result);
788
833
  console.log('');
789
834
  if (flags['draft-only']) {
790
835
  ui.info('Draft only: nothing was created. Drop --draft-only to create it.');
791
836
  return;
792
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
+ }
793
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.`);
794
845
  const ok = await ui.confirm('Create this rule? It starts in test mode: it alerts, but blocks nothing');
795
846
  if (!ok) { ui.info('Nothing created'); return; }
796
847
  }
@@ -814,9 +865,12 @@ async function alertRuleFromText(args, flags) {
814
865
  ['Kind', r.executionMode === 'instant' ? 'instant (every request, as it arrives)' : `periodic (${r.schedule?.description || r.schedule?.cronExpression || '-'})`],
815
866
  ['Mode', `${r.mode || 'test'} (alerts only, no blocking)`],
816
867
  ['Application', (r.applications || []).join(', ') || '-'],
868
+ ...(data.firstRunQueued ? [['First run', 'queued now']] : []),
817
869
  ]);
870
+ printWarnings(data);
818
871
  console.log('');
819
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>`));
820
874
  }
821
875
 
822
876
  async function alertRuleTest(args, flags) {
@@ -824,6 +878,7 @@ async function alertRuleTest(args, flags) {
824
878
  const id = args[0];
825
879
  if (!id) {
826
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.'));
827
882
  process.exit(1);
828
883
  }
829
884
 
@@ -1021,8 +1076,170 @@ async function alertRuleExclusions(args, flags) {
1021
1076
  }
1022
1077
  }
1023
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
+
1024
1233
  // ── Alert Channels ──
1025
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
+
1026
1243
  async function alertChannelsList(args, flags) {
1027
1244
  requireAuth();
1028
1245
  const s = ui.spinner('Fetching alert channels');
@@ -1038,10 +1255,10 @@ async function alertChannelsList(args, flags) {
1038
1255
  ui.c.dim(ch.id || ch._id || ''),
1039
1256
  ch.name || '—',
1040
1257
  ch.type || '—',
1041
- ui.statusBadge(ch.enabled !== false ? 'enabled' : 'disabled'),
1042
- ch.target || ch.email || ch.webhookUrl || '—',
1258
+ ui.statusBadge(ch.status === 'Disabled' ? 'disabled' : 'active'),
1259
+ channelDestination(ch),
1043
1260
  ]);
1044
- ui.table(['ID', 'Name', 'Type', 'Status', 'Target'], rows);
1261
+ ui.table(['ID', 'Name', 'Type', 'Status', 'Destination'], rows);
1045
1262
  console.log('');
1046
1263
  } catch (err) {
1047
1264
  s.fail('Failed to fetch alert channels');
@@ -1056,22 +1273,27 @@ async function alertHistoryList(args, flags) {
1056
1273
  const s = ui.spinner('Fetching alert history');
1057
1274
  try {
1058
1275
  const query = { limit: flags.limit || 20 };
1276
+ if (flags.page) query.page = flags.page;
1059
1277
  const data = await api.get('/alert-history', { query });
1060
- const history = data.alerts || [];
1061
- 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)` : ''}`);
1062
1281
 
1063
1282
  if (flags.json) { ui.json(data); return; }
1064
1283
 
1065
1284
  console.log('');
1066
1285
  const rows = history.map(h => [
1067
1286
  ui.c.dim(h.id || h._id || ''),
1068
- h.ruleName || h.type || '—',
1069
- h.severity ? ui.statusBadge(h.severity) : '—',
1070
- ui.truncate(h.message || h.description || '', 40),
1071
- h.serviceName || '—',
1072
- 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),
1073
1292
  ]);
1074
- 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
+ }
1075
1297
  console.log('');
1076
1298
  } catch (err) {
1077
1299
  s.fail('Failed to fetch alert history');
package/cli.js CHANGED
@@ -311,9 +311,12 @@ const COMMANDS = {
311
311
  usage: 'securenow alerts <subcommand> [options]',
312
312
  sub: {
313
313
  rules: {
314
- desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, or tune alert rules',
315
- usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|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]',
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',
317
320
  flags: {
318
321
  json: 'Output as JSON',
319
322
  env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
@@ -329,8 +332,13 @@ const COMMANDS = {
329
332
  category: 'With create: query category (default: custom)',
330
333
  severity: 'With create: critical | high | medium | low',
331
334
  schedule: 'With create: cron expression (default */15 * * * *)',
332
- 'throttle-minutes': 'With create: notification throttle window in minutes',
335
+ 'throttle-minutes': 'With create: one alert per address every N minutes (default 15)',
333
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',
334
342
  'execution-mode': 'With create: scheduled | instant | hybrid',
335
343
  channel: 'With create: comma-separated alert channel ids (default: in-app)',
336
344
  'query-mapping-id': 'With create: reuse an existing saved query instead of --sql',
@@ -338,7 +346,7 @@ const COMMANDS = {
338
346
  'no-applications-all': 'With update: scope to explicit --apps list',
339
347
  apps: 'Comma-separated app keys (with create/update)',
340
348
  app: 'Application key for rule tests',
341
- 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)',
342
350
  status: 'With update: Active | Disabled | Paused. With list: filter by status',
343
351
  enable: 'With update: shortcut for --status Active',
344
352
  disable: 'With update: shortcut for --status Disabled',
@@ -358,7 +366,7 @@ const COMMANDS = {
358
366
  notification: 'Review notification id tied to tuning',
359
367
  note: 'Review note tied to tuning',
360
368
  yes: 'Confirm write prompts',
361
- force: 'Confirm write prompts',
369
+ force: 'Confirm write prompts. With from-text --yes: create even when the check matches data right now',
362
370
  },
363
371
  run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
364
372
  },
package/mcp/catalog.js CHANGED
@@ -1419,13 +1419,13 @@ const TOOLS = [
1419
1419
  {
1420
1420
  name: 'securenow_alert_rule_create',
1421
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 (rows without an ip never alert). Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
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
1423
  scope: 'alerts:write',
1424
1424
  readOnly: false,
1425
1425
  confirm: true,
1426
1426
  method: 'POST',
1427
1427
  endpoint: '/alert-rules',
1428
- bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle'],
1428
+ bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle', 'allowNoIp'],
1429
1429
  inputSchema: objectSchema({
1430
1430
  name: string('Rule name.'),
1431
1431
  description: string('What it catches, in a sentence.'),
@@ -1438,7 +1438,8 @@ const TOOLS = [
1438
1438
  sqlQuery: string('Periodic rules: the detection SELECT.'),
1439
1439
  schedule: { type: 'object', additionalProperties: true, description: 'Periodic rules: {cronExpression: "* * * * *", description: "Every minute"}. Default every 15 minutes.' },
1440
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 rule per window.' },
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.'),
1442
1443
  ...confirmSchema,
1443
1444
  }, ['name', 'confirm', 'reason']),
1444
1445
  },
@@ -1536,9 +1537,9 @@ const TOOLS = [
1536
1537
  applicationsAll: boolean('Scope rule to all applications.'),
1537
1538
  applications: arrayOfStrings('Application keys when applicationsAll is false.'),
1538
1539
  schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
1539
- 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.' },
1540
1541
  alertChannelIds: arrayOfStrings('Alert channel ids.'),
1541
- 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).'),
1542
1543
  reviewNote: string('Review note.'),
1543
1544
  reviewNotificationId: string('Notification id tied to this review.'),
1544
1545
  ...confirmSchema,
@@ -1832,7 +1833,7 @@ const TOOLS = [
1832
1833
  {
1833
1834
  name: 'securenow_alert_rule_test',
1834
1835
  title: 'Test Alert Rule',
1835
- 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.',
1836
1837
  scope: 'alerts:write',
1837
1838
  readOnly: false,
1838
1839
  confirm: true,
@@ -1843,7 +1844,7 @@ const TOOLS = [
1843
1844
  inputSchema: objectSchema({
1844
1845
  id: string('Alert rule id.'),
1845
1846
  applicationKey: string('Application key to test.'),
1846
- 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).'),
1847
1848
  ...confirmSchema,
1848
1849
  }, ['id', 'confirm', 'reason']),
1849
1850
  },
@@ -1880,6 +1881,53 @@ const TOOLS = [
1880
1881
  testId: string('Alert rule test id.'),
1881
1882
  }, ['id', 'testId']),
1882
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
+ },
1883
1931
  {
1884
1932
  name: 'securenow_alert_rule_exclusions_list',
1885
1933
  title: 'List Alert Rule Exclusions',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.16.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",