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