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/NPM_README.md +8 -1
- package/SKILL-CLI.md +33 -2
- package/cli/client.js +4 -1
- package/cli/human.js +63 -0
- package/cli/monitor.js +43 -0
- package/cli/security.js +357 -16
- package/cli.js +33 -11
- package/esm-detect.js +57 -0
- package/mcp/catalog.js +173 -15
- package/mcp/server.js +17 -2
- package/package.json +3 -2
- package/register.js +3 -7
- package/tracing.js +7 -13
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(
|
|
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
|
|
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
|
|
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.
|
|
1042
|
-
ch
|
|
1377
|
+
ui.statusBadge(ch.status === 'Disabled' ? 'disabled' : 'active'),
|
|
1378
|
+
channelDestination(ch),
|
|
1043
1379
|
]);
|
|
1044
|
-
ui.table(['ID', 'Name', 'Type', 'Status', '
|
|
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
|
-
|
|
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.
|
|
1069
|
-
h.
|
|
1070
|
-
|
|
1071
|
-
h.
|
|
1072
|
-
ui.timeAgo(h.
|
|
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', '
|
|
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:
|
|
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
|
-
|
|
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',
|