securenow 8.16.0 → 8.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/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];
@@ -42,6 +55,9 @@ async function alertRulesRoute(args, flags) {
42
55
  if (sub === 'from-text' || sub === 'draft') {
43
56
  return alertRuleFromText(args.slice(1), flags);
44
57
  }
58
+ if (sub === 'change') {
59
+ return alertRuleChange(args.slice(1), flags);
60
+ }
45
61
  if (sub === 'show') {
46
62
  return alertRuleShow(args.slice(1), flags);
47
63
  }
@@ -78,6 +94,18 @@ async function alertRulesRoute(args, flags) {
78
94
  if (sub === 'exclusions') {
79
95
  return alertRuleExclusions(args.slice(1), flags);
80
96
  }
97
+ if (sub === 'runs') {
98
+ return alertRuleRuns(args.slice(1), flags);
99
+ }
100
+ if (sub === 'request-promotion') {
101
+ return alertRuleRequestPromotion(args.slice(1), flags);
102
+ }
103
+ if (sub === 'run' || sub === 'run-now') {
104
+ return alertRuleRunNow(args.slice(1), flags);
105
+ }
106
+ if (sub === 'why' || sub === 'explain') {
107
+ return alertRuleWhy(args.slice(1), flags);
108
+ }
81
109
  if (sub === 'promote') {
82
110
  return alertRuleSetMode(args.slice(1), flags, 'prod');
83
111
  }
@@ -115,7 +143,9 @@ async function alertRuleSetMode(args, flags, mode) {
115
143
  s.stop(`${ui.truncate(id, 12)} → ${mode}`);
116
144
  okCount++;
117
145
  } catch (err) {
118
- s.fail(`Failed on ${ui.truncate(id, 12)}: ${err.message}`);
146
+ s.fail(isSystemRuleRefusal(err)
147
+ ? `${ui.truncate(id, 12)} is a system rule (a read-only template): duplicate it first: securenow alerts rules duplicate ${id}`
148
+ : `Failed on ${ui.truncate(id, 12)}: ${err.message}`);
119
149
  failures.push(id);
120
150
  }
121
151
  }
@@ -284,6 +314,7 @@ async function alertRuleCreate(args, flags) {
284
314
  }
285
315
  if (flags['no-throttle']) body.throttle = { enabled: false, minutes: 0 };
286
316
  else if (flags['throttle-minutes']) body.throttle = { enabled: true, minutes: Number(flags['throttle-minutes']) };
317
+ if (flags['allow-no-ip']) body.allowNoIp = true;
287
318
 
288
319
  const channelStr = flags.channel || flags.channels;
289
320
  if (channelStr) {
@@ -326,10 +357,12 @@ async function alertRuleCreate(args, flags) {
326
357
  ['Severity', r.severity || '-'],
327
358
  ['Applications', r.applicationsAll ? 'all apps' : ((r.applications || []).join(', ') || '-')],
328
359
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '-')],
329
- ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
360
+ ['Throttle', throttleLabel(r.throttle)],
330
361
  ['Query', r.queryMappingId?.name || r.queryMappingId || '-'],
331
362
  ['Validation', flags['no-validate'] ? 'skipped' : 'dry-run clean'],
363
+ ...(data.firstRunQueued ? [['First run', `queued now (see: securenow alerts rules runs ${createdId})`]] : []),
332
364
  ]);
365
+ printWarnings(data);
333
366
  console.log('');
334
367
  ui.success(`Created. View it with: securenow alerts rules show ${createdId}`);
335
368
  } catch (err) {
@@ -369,10 +402,12 @@ async function alertRuleShow(args, flags) {
369
402
  ['System rule', r.isSystem ? 'yes' : 'no'],
370
403
  ['Applications', appLine],
371
404
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '—')],
372
- ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
405
+ ['Throttle', throttleLabel(r.throttle)],
373
406
  ['Query', r.queryMappingId?.name || '—'],
374
407
  ]);
375
408
  console.log('');
409
+ 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>`));
410
+ console.log('');
376
411
  } catch (err) {
377
412
  s.fail('Failed to fetch alert rule');
378
413
  throw err;
@@ -443,9 +478,14 @@ async function alertRuleUpdate(args, flags) {
443
478
 
444
479
  // Lifecycle mode promote/demote (also available as: alerts rules promote|demote <id>).
445
480
  if (flags.mode === 'test' || flags.mode === 'prod') body.mode = flags.mode;
481
+ if (flags.severity) {
482
+ const severity = String(flags.severity).toLowerCase();
483
+ if (!['critical', 'high', 'medium', 'low'].includes(severity)) { ui.error('--severity must be critical, high, medium or low'); process.exit(1); }
484
+ body.severity = severity;
485
+ }
446
486
 
447
487
  if (Object.keys(body).length === 0) {
448
- ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), or --mode test|prod (or: alerts rules promote/demote <id>).');
488
+ ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), --severity, or --mode test|prod (or: alerts rules promote/demote <id>).');
449
489
  process.exit(1);
450
490
  }
451
491
 
@@ -453,10 +493,14 @@ async function alertRuleUpdate(args, flags) {
453
493
  try {
454
494
  await api.put(`/alert-rules/${id}`, body);
455
495
  s.stop('Updated');
456
- const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`].filter(Boolean);
496
+ const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`, body.severity && `severity → ${body.severity}`].filter(Boolean);
457
497
  ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
458
498
  } catch (err) {
459
499
  s.fail('Failed to update alert rule');
500
+ if (isSystemRuleRefusal(err)) {
501
+ ui.info(`System rules are read-only templates. Duplicate it first: securenow alerts rules duplicate ${id}`);
502
+ process.exit(1);
503
+ }
460
504
  throw err;
461
505
  }
462
506
  }
@@ -472,11 +516,13 @@ async function alertRuleSetSql(args, flags) {
472
516
  const body = { sqlQuery };
473
517
  if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
474
518
  if (flags.category) body.category = flags.category;
519
+ if (flags['allow-no-ip']) body.allowNoIp = true;
475
520
 
476
521
  const s = ui.spinner('Updating rule SQL');
477
522
  try {
478
523
  const data = await api.put(`/alert-rules/${id}`, body);
479
524
  s.stop('Rule SQL updated');
525
+ printWarnings(data);
480
526
  const ok = flags['no-validate'] ? null : await validateRuleById(id, flags);
481
527
  if (ok && ok.status === 'failed') {
482
528
  ui.warn(`Saved, but the new SQL failed a dry-run: ${ok.error || 'unknown error'}`);
@@ -783,14 +829,30 @@ async function alertRuleFromText(args, flags) {
783
829
  }
784
830
  s.stop('Draft ready');
785
831
 
832
+ // A draft that matches the data right now alerts the moment it is saved: a real ongoing attack,
833
+ // or (more often) a bad baseline. Unattended (--yes) creation stops there unless --force.
834
+ const matches = Number(result.check?.matches || 0);
835
+ const refuse = Boolean(flags.yes) && !flags.force && !flags['draft-only'] && matches > 0;
836
+ 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.`;
837
+
786
838
  if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
839
+ if (flags.json && refuse) {
840
+ ui.json({ ...result, created: false, refused: 'check_matches', message: refusal });
841
+ process.exit(1);
842
+ }
787
843
  printDraft(result);
788
844
  console.log('');
789
845
  if (flags['draft-only']) {
790
846
  ui.info('Draft only: nothing was created. Drop --draft-only to create it.');
791
847
  return;
792
848
  }
849
+ if (refuse) {
850
+ ui.warn(refusal);
851
+ 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.');
852
+ process.exit(1);
853
+ }
793
854
  if (!flags.yes) {
855
+ 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
856
  const ok = await ui.confirm('Create this rule? It starts in test mode: it alerts, but blocks nothing');
795
857
  if (!ok) { ui.info('Nothing created'); return; }
796
858
  }
@@ -814,9 +876,93 @@ async function alertRuleFromText(args, flags) {
814
876
  ['Kind', r.executionMode === 'instant' ? 'instant (every request, as it arrives)' : `periodic (${r.schedule?.description || r.schedule?.cronExpression || '-'})`],
815
877
  ['Mode', `${r.mode || 'test'} (alerts only, no blocking)`],
816
878
  ['Application', (r.applications || []).join(', ') || '-'],
879
+ ...(data.firstRunQueued ? [['First run', 'queued now']] : []),
817
880
  ]);
881
+ printWarnings(data);
818
882
  console.log('');
819
883
  ui.success(`Watching. Promote it when it has proved itself: securenow alerts rules promote ${id}`);
884
+ console.log(ui.c.dim(` Runs: securenow alerts rules runs ${id} Why no alert: securenow alerts rules why ${id} --ip <ip>`));
885
+ }
886
+
887
+ // Describe a change to an existing rule in English: the API rewrites the rule from its current
888
+ // version and the app's data, checks the result, and the change is applied as a new rule version.
889
+ async function alertRuleChange(args, flags) {
890
+ requireAuth();
891
+ const id = args[0];
892
+ const text = args.slice(1).join(' ').trim() || (typeof flags.text === 'string' ? flags.text.trim() : '');
893
+ if (!id || !text) {
894
+ ui.error('Usage: securenow alerts rules change <rule-id> "<what should change>" [--app <key>] [--yes] [--draft-only] [--json]');
895
+ console.log(ui.c.dim(' Example: securenow alerts rules change <id> "raise the threshold to 20 failed sign-ins"'));
896
+ process.exit(1);
897
+ }
898
+ const body = { text, ruleId: id };
899
+ if (flags.app) body.applicationKey = flags.app;
900
+
901
+ const s = ui.spinner('Rewriting the rule from your app\'s data');
902
+ let result;
903
+ try {
904
+ const started = await api.post('/alert-rules/draft', body);
905
+ result = await waitForDraft(started.draftId, s);
906
+ } catch (err) {
907
+ s.fail('Failed to draft the change');
908
+ throw err;
909
+ }
910
+ if (result.status !== 'complete') {
911
+ s.fail(result.error || 'Could not draft the change');
912
+ process.exit(1);
913
+ }
914
+ s.stop('Change drafted');
915
+
916
+ const matches = Number(result.check?.matches || 0);
917
+ const refuse = Boolean(flags.yes) && !flags.force && !flags['draft-only'] && matches > 0;
918
+ if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
919
+ if (flags.json && refuse) {
920
+ ui.json({ ...result, applied: false, refused: 'check_matches' });
921
+ process.exit(1);
922
+ }
923
+ if (result.current?.kind === 'instant') {
924
+ console.log('');
925
+ console.log(ui.c.dim(` Before: ${result.current.matchMode === 'all' ? 'all of' : 'any of'} ${(result.current.conditions || []).map((c) => `${c.field} ${c.operator} ${c.value}`).join('; ')}`));
926
+ } else if (result.current?.sql) {
927
+ console.log('');
928
+ console.log(ui.c.dim(' Before'));
929
+ for (const line of String(result.current.sql).split('\n')) console.log(ui.c.dim(` ${line}`));
930
+ }
931
+ printDraft(result);
932
+ console.log('');
933
+ if (flags['draft-only']) { ui.info('Draft only: nothing was changed.'); return; }
934
+ if (refuse) {
935
+ ui.warn(`Not applied: the changed rule matches ${matches} row${matches === 1 ? '' : 's'} in your data right now, so it would alert at once. Apply anyway with --yes --force.`);
936
+ process.exit(1);
937
+ }
938
+ if (!flags.yes) {
939
+ if (matches > 0) ui.warn(`The changed rule matches ${matches} row${matches === 1 ? '' : 's'} right now (above): it alerts as soon as it is applied.`);
940
+ const ok = await ui.confirm('Apply this change? It is saved as a new rule version (roll back with alerts rules rollback)');
941
+ if (!ok) { ui.info('Nothing changed'); return; }
942
+ }
943
+
944
+ const d = result.draft || {};
945
+ const update = {
946
+ name: d.name,
947
+ description: d.summary || undefined,
948
+ severity: d.severity,
949
+ reviewNote: `Changed in English: ${text}`.slice(0, 1000),
950
+ // Instant: only what the draft decides, so the rule keeps its own instant-block settings.
951
+ ...(d.executionMode === 'instant'
952
+ ? { instant: { matchMode: d.instant?.matchMode, conditions: d.instant?.conditions || [], environments: d.instant?.environments || [] } }
953
+ : { sqlQuery: d.sqlQuery, schedule: d.schedule }),
954
+ };
955
+ const c = ui.spinner('Applying the change');
956
+ try {
957
+ const data = await api.put(`/alert-rules/${id}`, update);
958
+ c.stop(`Changed: version ${data.configVersion || '?'}`);
959
+ printWarnings(data);
960
+ if (flags.json) ui.json(data);
961
+ else console.log(ui.c.dim(` History: securenow alerts rules versions ${id} Runs: securenow alerts rules runs ${id}`));
962
+ } catch (err) {
963
+ c.fail('Failed to apply the change');
964
+ throw err;
965
+ }
820
966
  }
821
967
 
822
968
  async function alertRuleTest(args, flags) {
@@ -824,6 +970,7 @@ async function alertRuleTest(args, flags) {
824
970
  const id = args[0];
825
971
  if (!id) {
826
972
  ui.error('Usage: securenow alerts rules test <rule-id> [--app <key>] [--mode dry_run|live] [--wait]');
973
+ 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
974
  process.exit(1);
828
975
  }
829
976
 
@@ -1021,8 +1168,197 @@ async function alertRuleExclusions(args, flags) {
1021
1168
  }
1022
1169
  }
1023
1170
 
1171
+ // ── Rule runs: history, run now, why didn't this alert ──
1172
+
1173
+ const RUN_OUTCOME_COLORS = {
1174
+ notified: 'green',
1175
+ storm: 'red',
1176
+ error: 'red',
1177
+ throttled: 'yellow',
1178
+ suppressed_blocked: 'yellow',
1179
+ suppressed_trusted: 'yellow',
1180
+ no_ip: 'yellow',
1181
+ };
1182
+
1183
+ function runOutcomeCell(outcome) {
1184
+ const color = RUN_OUTCOME_COLORS[outcome];
1185
+ return color && ui.c[color] ? ui.c[color](outcome) : ui.c.dim(outcome || '-');
1186
+ }
1187
+
1188
+ function runAddressesCell(run) {
1189
+ const parts = [];
1190
+ if (run.notifiedIps?.length) parts.push(`notified ${run.notifiedIps.slice(0, 2).join(', ')}${run.notifiedIps.length > 2 ? '…' : ''}`);
1191
+ const left = run.suppressedIps || [];
1192
+ if (left.length) {
1193
+ const reasons = [...new Set(left.map((x) => x.reason))].join('/');
1194
+ parts.push(`${left.length} ${reasons}`);
1195
+ }
1196
+ if (!parts.length && run.ips?.length) parts.push(run.ips.slice(0, 2).join(', ') + (run.ips.length > 2 ? '…' : ''));
1197
+ return parts.join('; ') || '—';
1198
+ }
1199
+
1200
+ async function alertRuleRuns(args, flags) {
1201
+ requireAuth();
1202
+ const id = args[0];
1203
+ if (!id) {
1204
+ ui.error('Usage: securenow alerts rules runs <rule-id> [--limit 20] [--since 1h]');
1205
+ process.exit(1);
1206
+ }
1207
+ const query = { limit: flags.limit || 20 };
1208
+ if (flags.since) query.since = flags.since;
1209
+ const s = ui.spinner('Fetching rule runs');
1210
+ let data;
1211
+ try {
1212
+ data = await api.get(`/alert-rules/${id}/runs`, { query });
1213
+ s.stop(`Found ${(data.runs || []).length} run${(data.runs || []).length === 1 ? '' : 's'}`);
1214
+ } catch (err) {
1215
+ s.fail('Failed to fetch rule runs');
1216
+ throw err;
1217
+ }
1218
+ if (flags.json) { ui.json(data); return; }
1219
+
1220
+ const runs = data.runs || [];
1221
+ console.log('');
1222
+ ui.heading(data.ruleName || 'Rule runs');
1223
+ if (data.nextRunAt) console.log(ui.c.dim(` Next scheduled run: ${new Date(data.nextRunAt).toISOString().replace('T', ' ').slice(0, 19)} UTC`));
1224
+ console.log('');
1225
+ if (runs.length === 0) {
1226
+ ui.info(data.executionMode === 'instant'
1227
+ ? 'No matches recorded. An instant rule records a run when a request matches its conditions.'
1228
+ : `No runs recorded. Run it now: securenow alerts rules run ${id}`);
1229
+ return;
1230
+ }
1231
+ ui.table(['Started (UTC)', 'Trigger', 'Outcome', 'Rows', 'Addresses', 'Detail'], runs.map((run) => [
1232
+ new Date(run.startedAt).toISOString().replace('T', ' ').slice(0, 19),
1233
+ run.trigger,
1234
+ runOutcomeCell(run.outcome),
1235
+ String(run.rows ?? 0),
1236
+ ui.truncate(runAddressesCell(run), 44),
1237
+ ui.truncate(run.error || run.detail || (run.notificationId ? `notification ${run.notificationId}` : ''), 60),
1238
+ ]));
1239
+ console.log('');
1240
+ console.log(ui.c.dim(` Why no alert for an address: securenow alerts rules why ${id} --ip <ip> [--since 1h]`));
1241
+ console.log('');
1242
+ }
1243
+
1244
+ async function alertRuleRunNow(args, flags) {
1245
+ requireAuth();
1246
+ const id = args[0];
1247
+ if (!id) {
1248
+ ui.error('Usage: securenow alerts rules run <rule-id> [--no-wait] [--json]');
1249
+ process.exit(1);
1250
+ }
1251
+ const requestedAt = Date.now();
1252
+ const s = ui.spinner('Queueing a run');
1253
+ let queued;
1254
+ try {
1255
+ queued = await api.post(`/alert-rules/${id}/run`, {});
1256
+ } catch (err) {
1257
+ s.fail('Failed to run the rule');
1258
+ throw err;
1259
+ }
1260
+ if (flags['no-wait']) {
1261
+ s.stop(queued.message || 'Queued');
1262
+ if (flags.json) ui.json(queued);
1263
+ return;
1264
+ }
1265
+
1266
+ s.update('Waiting for the run to finish');
1267
+ let run = null;
1268
+ for (let i = 0; i < 30 && !run; i++) {
1269
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1270
+ const data = await api.get(`/alert-rules/${id}/runs`, { query: { limit: 5 } });
1271
+ run = (data.runs || []).find((r) => r.trigger === 'manual' && new Date(r.startedAt).getTime() >= requestedAt - 15000) || null;
1272
+ }
1273
+ if (!run) {
1274
+ s.stop('Queued; the run has not finished yet');
1275
+ ui.info(`Check later: securenow alerts rules runs ${id}`);
1276
+ if (flags.json) ui.json({ ...queued, run: null });
1277
+ return;
1278
+ }
1279
+ s.stop(`Run finished: ${run.outcome}`);
1280
+ if (flags.json) { ui.json({ ...queued, run }); return; }
1281
+ console.log('');
1282
+ ui.keyValue([
1283
+ ['Outcome', runOutcomeCell(run.outcome)],
1284
+ ['Rows', String(run.rows ?? 0)],
1285
+ ['Addresses', String(run.uniqueIps ?? 0)],
1286
+ ['Notified', (run.notifiedIps || []).join(', ') || '—'],
1287
+ ['Left out', (run.suppressedIps || []).map((x) => `${x.ip} (${x.reason})`).join(', ') || '—'],
1288
+ ['Notification', run.notificationId || '—'],
1289
+ ['Duration', `${run.durationMs} ms`],
1290
+ ...(run.detail ? [['Detail', run.detail]] : []),
1291
+ ...(run.error ? [['Error', run.error]] : []),
1292
+ ]);
1293
+ console.log('');
1294
+ }
1295
+
1296
+ async function alertRuleWhy(args, flags) {
1297
+ requireAuth();
1298
+ const id = args[0];
1299
+ const ip = flags.ip || args[1];
1300
+ if (!id || !ip || ip === true) {
1301
+ ui.error('Usage: securenow alerts rules why <rule-id> --ip <ip> [--since 1h|30m|2d|<iso>] [--json]');
1302
+ process.exit(1);
1303
+ }
1304
+ const query = { ip: String(ip), since: flags.since || '1h' };
1305
+ const s = ui.spinner(`Checking what the rule did with ${ip}`);
1306
+ let data;
1307
+ try {
1308
+ data = await api.get(`/alert-rules/${id}/explain`, { query });
1309
+ s.stop('');
1310
+ } catch (err) {
1311
+ s.fail('Failed to explain the rule');
1312
+ throw err;
1313
+ }
1314
+ if (flags.json) { ui.json(data); return; }
1315
+ console.log('');
1316
+ console.log(` ${ui.c.bold(data.verdict || '')}`);
1317
+ console.log('');
1318
+ for (const finding of data.findings || []) {
1319
+ console.log(` • ${finding.title}`);
1320
+ if (finding.detail) console.log(ui.c.dim(` ${finding.detail}`));
1321
+ }
1322
+ console.log('');
1323
+ }
1324
+
1325
+ // An agent hands the promotion of its test-mode rule to a person: the request waits on the rules
1326
+ // page and in `securenow human promotions` until someone approves or denies it.
1327
+ async function alertRuleRequestPromotion(args, flags) {
1328
+ requireAuth();
1329
+ const id = args[0];
1330
+ const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
1331
+ if (!id || !reason) {
1332
+ ui.error('Usage: securenow alerts rules request-promotion <rule-id> --reason "why it is ready for prod" [--evidence "<text>|@file"]');
1333
+ process.exit(1);
1334
+ }
1335
+ const body = { reason };
1336
+ if (typeof flags.evidence === 'string') {
1337
+ body.evidence = flags.evidence.startsWith('@') ? fs.readFileSync(flags.evidence.slice(1), 'utf8') : flags.evidence;
1338
+ }
1339
+ const s = ui.spinner('Asking for promotion');
1340
+ try {
1341
+ const data = await api.post(`/alert-rules/${id}/promotion-requests`, body);
1342
+ s.stop(data.existing ? 'A request for this rule is already waiting' : 'Promotion requested');
1343
+ if (flags.json) { ui.json(data); return; }
1344
+ ui.info(data.message || 'Waiting for a person to approve it.');
1345
+ if (data.request?.id) console.log(ui.c.dim(` Request ${data.request.id}. A person decides with: securenow human promotions`));
1346
+ } catch (err) {
1347
+ s.fail('Failed to request promotion');
1348
+ throw err;
1349
+ }
1350
+ }
1351
+
1024
1352
  // ── Alert Channels ──
1025
1353
 
1354
+ function channelDestination(ch) {
1355
+ if (ch.type === 'email') return ch.email || '—';
1356
+ if (ch.type === 'securenow') return 'in-app (SecureNow inbox)';
1357
+ if (ch.type === 'slack') return 'Slack webhook (URL hidden)';
1358
+ if (ch.type === 'webhook') return `${ch.webhookMethod || 'POST'} webhook (URL hidden)`;
1359
+ return '—';
1360
+ }
1361
+
1026
1362
  async function alertChannelsList(args, flags) {
1027
1363
  requireAuth();
1028
1364
  const s = ui.spinner('Fetching alert channels');
@@ -1038,10 +1374,10 @@ async function alertChannelsList(args, flags) {
1038
1374
  ui.c.dim(ch.id || ch._id || ''),
1039
1375
  ch.name || '—',
1040
1376
  ch.type || '—',
1041
- ui.statusBadge(ch.enabled !== false ? 'enabled' : 'disabled'),
1042
- ch.target || ch.email || ch.webhookUrl || '—',
1377
+ ui.statusBadge(ch.status === 'Disabled' ? 'disabled' : 'active'),
1378
+ channelDestination(ch),
1043
1379
  ]);
1044
- ui.table(['ID', 'Name', 'Type', 'Status', 'Target'], rows);
1380
+ ui.table(['ID', 'Name', 'Type', 'Status', 'Destination'], rows);
1045
1381
  console.log('');
1046
1382
  } catch (err) {
1047
1383
  s.fail('Failed to fetch alert channels');
@@ -1056,22 +1392,27 @@ async function alertHistoryList(args, flags) {
1056
1392
  const s = ui.spinner('Fetching alert history');
1057
1393
  try {
1058
1394
  const query = { limit: flags.limit || 20 };
1395
+ if (flags.page) query.page = flags.page;
1059
1396
  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)` : ''}`);
1397
+ const history = data.items || data.alerts || [];
1398
+ const total = data.pagination?.total ?? data.totalItems;
1399
+ s.stop(`Found ${history.length} alert${history.length !== 1 ? 's' : ''}${total ? ` (${total} total)` : ''}`);
1062
1400
 
1063
1401
  if (flags.json) { ui.json(data); return; }
1064
1402
 
1065
1403
  console.log('');
1066
1404
  const rows = history.map(h => [
1067
1405
  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),
1406
+ ui.truncate(h.subject || h.ruleName || '—', 40),
1407
+ `${h.channelType || '—'}${h.channelName ? ` · ${ui.truncate(h.channelName, 20)}` : ''}`,
1408
+ h.status ? ui.statusBadge(h.status) : '—',
1409
+ h.applicationKey ? ui.truncate(h.applicationKey, 12) : '—',
1410
+ ui.timeAgo(h.createdAt),
1073
1411
  ]);
1074
- ui.table(['ID', 'Rule', 'Severity', 'Message', 'App', 'Triggered'], rows);
1412
+ ui.table(['ID', 'Subject', 'Channel', 'Status', 'App', 'Sent'], rows);
1413
+ if (data.pagination?.totalPages > 1) {
1414
+ console.log(ui.c.dim(` Page ${data.pagination.page} of ${data.pagination.totalPages}: --page <n>`));
1415
+ }
1075
1416
  console.log('');
1076
1417
  } catch (err) {
1077
1418
  s.fail('Failed to fetch alert history');
package/cli.js CHANGED
@@ -195,6 +195,7 @@ const COMMANDS = {
195
195
  sub: {
196
196
  list: { desc: 'List recent logs', flags: { app: 'App key', env: 'Environment (production, staging, preview, local, or all)', environment: 'Alias for --env', limit: 'Max results (default 200)', since: 'Time window (e.g. 30m, 6h, 2d)', minutes: 'Time window in minutes (alias for --since Nm)', level: 'Filter by level (error, warn, info, debug)', start: 'Start time (ISO 8601)', end: 'End time (ISO 8601)' }, run: (a, f) => require('./cli/monitor').logsList(a, f) },
197
197
  trace: { desc: 'Show logs for a trace', usage: 'securenow logs trace <traceId>', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env' }, run: (a, f) => require('./cli/monitor').logsTrace(a, f) },
198
+ errors: { desc: 'Error lines grouped by message (ids and numbers normalized), most frequent first', usage: 'securenow logs errors [--app <key>] [--env <env>] [--since 1h] [--warn] [--limit 20]', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env', since: 'Window, e.g. 30m, 6h, 2d (default 1h)', warn: 'Include warnings', limit: 'Max groups (default 20)', json: 'Output as JSON' }, run: (a, f) => require('./cli/monitor').logsErrors(a, f) },
198
199
  },
199
200
  defaultSub: 'list',
200
201
  },
@@ -249,9 +250,10 @@ const COMMANDS = {
249
250
  },
250
251
  human: {
251
252
  desc: 'Work the human action queue prepared by SecureNow AI',
252
- usage: 'securenow human <list|show|block|fp|report|action|prompt|work> [row|notificationId:ip] [options]',
253
+ usage: 'securenow human <list|show|block|fp|report|action|prompt|work|promotions|approve-promotion|deny-promotion> [row|notificationId:ip|request-id] [options]',
253
254
  flags: {
254
255
  json: 'Output as JSON',
256
+ note: 'With approve-promotion / deny-promotion: a note recorded with the decision',
255
257
  page: 'Queue page number',
256
258
  limit: 'Queue page size',
257
259
  search: 'Search IP, rule, path, or verdict',
@@ -286,6 +288,9 @@ const COMMANDS = {
286
288
  action: { desc: 'Approve/reject/execute a case-level proposed action', usage: 'securenow human action <row|notificationId> [actionKey] --status approved --yes --reason "..."', run: (a, f) => require('./cli/human').action(a, f) },
287
289
  prompt: { desc: 'Print a Codex/Claude MCP prompt for row or queue work', usage: 'securenow human prompt [row|notificationId:ip] [--limit 10]', run: (a, f) => require('./cli/human').prompt(a, f) },
288
290
  work: { desc: 'List the queue and print the MCP runbook to work it deeply', usage: 'securenow human work [--limit 10]', run: (a, f) => require('./cli/human').work(a, f) },
291
+ promotions: { desc: 'List requests to promote a test-mode rule to prod (agents ask, a person decides)', usage: 'securenow human promotions [--status pending|approved|denied|all]', run: (a, f) => require('./cli/human').promotions(a, f) },
292
+ 'approve-promotion': { desc: 'Approve a promotion request: the rule goes to prod', usage: 'securenow human approve-promotion <request-id> --yes [--note "..."]', run: (a, f) => require('./cli/human').approvePromotion(a, f) },
293
+ 'deny-promotion': { desc: 'Deny a promotion request: the rule stays in test mode', usage: 'securenow human deny-promotion <request-id> --reason "..."', run: (a, f) => require('./cli/human').denyPromotion(a, f) },
289
294
  },
290
295
  defaultSub: 'list',
291
296
  },
@@ -311,9 +316,14 @@ const COMMANDS = {
311
316
  usage: 'securenow alerts <subcommand> [options]',
312
317
  sub: {
313
318
  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]',
319
+ desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, run, explain, or tune alert rules',
320
+ 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'
321
+ + ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]\n'
322
+ + ' securenow alerts rules change <id> "<what should change>" [--yes|--draft-only] rewrite a rule from English, checked, applied as a new version\n'
323
+ + ' securenow alerts rules runs <id> [--limit 20] [--since 1h] what each run saw and why it did or didn\'t notify\n'
324
+ + ' securenow alerts rules run <id> [--no-wait] run a periodic rule now\n'
325
+ + ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address\n'
326
+ + ' securenow alerts rules request-promotion <id> --reason "..." ask a person to promote a test-mode rule (for agents)',
317
327
  flags: {
318
328
  json: 'Output as JSON',
319
329
  env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
@@ -327,10 +337,15 @@ const COMMANDS = {
327
337
  nlp: 'With create: plain-English intent stored on the query',
328
338
  text: 'Alias for --nlp',
329
339
  category: 'With create: query category (default: custom)',
330
- severity: 'With create: critical | high | medium | low',
340
+ severity: 'With create/update: critical | high | medium | low',
331
341
  schedule: 'With create: cron expression (default */15 * * * *)',
332
- 'throttle-minutes': 'With create: notification throttle window in minutes',
342
+ 'throttle-minutes': 'With create: one alert per address every N minutes (default 15)',
333
343
  'no-throttle': 'With create: disable throttling',
344
+ 'allow-no-ip': 'With create/set-sql: save SQL that returns no ip column (it will never notify)',
345
+ ip: 'With why: the address to explain',
346
+ since: 'With runs/why: window, e.g. 30m, 1h, 2d or an ISO date (why defaults to 1h)',
347
+ limit: 'With runs: how many runs to show (default 20)',
348
+ 'no-wait': 'With run: queue the run and return without waiting for it',
334
349
  'execution-mode': 'With create: scheduled | instant | hybrid',
335
350
  channel: 'With create: comma-separated alert channel ids (default: in-app)',
336
351
  'query-mapping-id': 'With create: reuse an existing saved query instead of --sql',
@@ -338,7 +353,7 @@ const COMMANDS = {
338
353
  'no-applications-all': 'With update: scope to explicit --apps list',
339
354
  apps: 'Comma-separated app keys (with create/update)',
340
355
  app: 'Application key for rule tests',
341
- mode: 'With test: dry_run | live. With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
356
+ mode: 'With test: dry_run (default) | live (inserts 6 synthetic attack requests). With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
342
357
  status: 'With update: Active | Disabled | Paused. With list: filter by status',
343
358
  enable: 'With update: shortcut for --status Active',
344
359
  disable: 'With update: shortcut for --status Disabled',
@@ -351,14 +366,15 @@ const COMMANDS = {
351
366
  query: 'Alias for --sql',
352
367
  file: 'Read SQL from a file',
353
368
  'no-validate': 'With create/set-sql: skip the post-write dry-run + rollback',
354
- reason: 'Audit reason for a write',
369
+ reason: 'Audit reason for a write. With request-promotion: why the rule is ready for prod',
370
+ evidence: 'With request-promotion: evidence text or @file (runs, notifications, false-positive checks)',
355
371
  'apply-globally': 'Required for system query tuning',
356
372
  'reactivate-paused': 'Reactivate paused system copies after tuning',
357
373
  'expected-hash': 'Expected current SQL SHA-256 hash',
358
374
  notification: 'Review notification id tied to tuning',
359
375
  note: 'Review note tied to tuning',
360
376
  yes: 'Confirm write prompts',
361
- force: 'Confirm write prompts',
377
+ force: 'Confirm write prompts. With from-text --yes: create even when the check matches data right now',
362
378
  },
363
379
  run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
364
380
  },
@@ -767,8 +783,14 @@ const COMMANDS = {
767
783
  },
768
784
  mcp: {
769
785
  desc: 'Start the SecureNow MCP server over stdio',
770
- usage: 'securenow mcp',
771
- run: () => require('./mcp/server'),
786
+ usage: 'securenow mcp [--profile all|investigator|detection|responder]',
787
+ flags: {
788
+ profile: 'Serve a subset of the tools: investigator (read-only investigation), detection (alert rules), responder (alerts, blocks, limits). Default: all',
789
+ },
790
+ run: (a, f) => {
791
+ if (typeof f.profile === 'string') process.env.SECURENOW_MCP_PROFILE = f.profile;
792
+ require('./mcp/server');
793
+ },
772
794
  },
773
795
  version: {
774
796
  desc: 'Show CLI version',