securenow 8.16.0 → 8.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NPM_README.md +8 -1
- package/SKILL-CLI.md +11 -2
- package/cli/client.js +4 -1
- package/cli/security.js +236 -14
- package/cli.js +14 -6
- package/mcp/catalog.js +55 -7
- package/package.json +1 -1
package/NPM_README.md
CHANGED
|
@@ -352,6 +352,11 @@ npx securenow alerts rules create \
|
|
|
352
352
|
--severity high \
|
|
353
353
|
--nlp "single IP flooding /api/auth/signin or /api/auth/callback"
|
|
354
354
|
|
|
355
|
+
# What each run did, run one now, and why a rule didn't alert on an address (new in 8.17)
|
|
356
|
+
npx securenow alerts rules runs <rule-id>
|
|
357
|
+
npx securenow alerts rules run <rule-id>
|
|
358
|
+
npx securenow alerts rules why <rule-id> --ip 203.0.113.50 --since 2h
|
|
359
|
+
|
|
355
360
|
# View / manage alert rules, channels, and history
|
|
356
361
|
npx securenow alerts rules
|
|
357
362
|
npx securenow alerts rules show <rule-id>
|
|
@@ -363,7 +368,7 @@ npx securenow alerts channels
|
|
|
363
368
|
npx securenow alerts history --limit 20
|
|
364
369
|
```
|
|
365
370
|
|
|
366
|
-
Your detection SQL scopes app keys with the `__USER_APP_KEYS__` placeholder and selects an `ip` column for per-IP aggregation and remediation. SecureNow stores the query, runs it on the rule's schedule (default every 15 min), and routes matches to your in-app channel (override with `--channel <id>`). Requires an API key with `alerts:write`.
|
|
371
|
+
Your detection SQL scopes app keys with the `__USER_APP_KEYS__` placeholder and selects an `ip` column for per-IP aggregation and remediation (SQL without one is refused: it would never notify). SecureNow stores the query, runs it once right away and then on the rule's schedule (default every 15 min), and routes matches to your in-app channel (override with `--channel <id>`). The throttle sends one alert per address every 15 minutes by default; other addresses still alert. Requires an API key with `alerts:write`.
|
|
367
372
|
|
|
368
373
|
### IP Intelligence & Blocklist
|
|
369
374
|
|
|
@@ -617,6 +622,8 @@ npx securenow logs --json --level error | jq '.logs'
|
|
|
617
622
|
| | `alerts rules` | List rules (status, apps, schedule) |
|
|
618
623
|
| | `alerts rules show <id>` | Rule detail |
|
|
619
624
|
| | `alerts rules update <id> --applications-all` / `--apps k1,k2` | Application scope |
|
|
625
|
+
| | `alerts rules runs <id>` / `run <id>` | Run history (outcome of each run) / run a periodic rule now |
|
|
626
|
+
| | `alerts rules why <id> --ip <ip>` | Why the rule did or didn't alert on an address |
|
|
620
627
|
| | `alerts channels` | Alert channels |
|
|
621
628
|
| | `alerts history` | Alert history |
|
|
622
629
|
| **Investigate** | `ip <addr>` | IP intelligence |
|
package/SKILL-CLI.md
CHANGED
|
@@ -294,7 +294,10 @@ securenow alerts rules set-sql <id> --sql @new.sql # replace a CUSTOM rule's S
|
|
|
294
294
|
securenow alerts rules delete <id> --yes # delete a custom rule (system rules: disable instead)
|
|
295
295
|
securenow alerts rules update <id> --applications-all # all current & future apps
|
|
296
296
|
securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
|
|
297
|
-
securenow alerts rules test <id> --
|
|
297
|
+
securenow alerts rules test <id> --wait # dry-run an existing rule's query (default; --mode live inserts 6 synthetic attack requests)
|
|
298
|
+
securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
|
|
299
|
+
securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
|
|
300
|
+
securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
|
|
298
301
|
securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait # candidate test against a specific host rule
|
|
299
302
|
securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
|
|
300
303
|
securenow alerts rules exclusions <id> list # embedded rule exclusions
|
|
@@ -311,7 +314,13 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
|
|
|
311
314
|
`resources_string['service.name'] IN (__USER_APP_KEYS__)`, traces
|
|
312
315
|
(`signoz_traces.distributed_signoz_index_v3`) use `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``.
|
|
313
316
|
|
|
314
|
-
`alerts rules create` flags: `--name` (required); one of `--sql <sql|@file|->` or `--query-mapping-id <id>`; one of `--apps k1,k2` or `--applications-all`; optional `--description`, `--nlp` (plain-English intent), `--category`, `--severity critical|high|medium|low`, `--schedule <cron>` (default `*/15 * * * *`), `--throttle-minutes N` / `--no-throttle`, `--execution-mode scheduled|instant|hybrid`, `--channel id1,id2` (defaults to your in-app SecureNow channel). Detection SQL must scope app keys via the `__USER_APP_KEYS__` placeholder and select an `ip` column for per-IP aggregation/remediation. Requires `alerts:write`.
|
|
317
|
+
`alerts rules create` flags: `--name` (required); one of `--sql <sql|@file|->` or `--query-mapping-id <id>`; one of `--apps k1,k2` or `--applications-all`; optional `--description`, `--nlp` (plain-English intent), `--category`, `--severity critical|high|medium|low`, `--schedule <cron>` (default `*/15 * * * *`), `--throttle-minutes N` / `--no-throttle`, `--execution-mode scheduled|instant|hybrid`, `--channel id1,id2` (defaults to your in-app SecureNow channel). Detection SQL must scope app keys via the `__USER_APP_KEYS__` placeholder and select an `ip` column for per-IP aggregation/remediation: create and set-sql refuse SQL with no ip column (it would never notify) unless `--allow-no-ip`. A new periodic rule runs once right away. Requires `alerts:write`.
|
|
318
|
+
|
|
319
|
+
**Throttle = one alert per rule AND address** (8.17): an address alerted on within the window (default 15 min) is left out of the next runs' notifications, and other addresses still alert. Alert storms keep one alert per rule per window.
|
|
320
|
+
|
|
321
|
+
**When a rule stays quiet,** don't guess: `alerts rules runs <id>` shows every run's outcome — `notified`, `no_rows`, `throttled`, `suppressed_blocked` (already blocked, often by another rule's instant block), `suppressed_trusted`, `no_ip` (rows without an ip column), `storm`, `auto_resolved` (dismissed by an exclusion, trusted IP or verified bot), `skipped`, `error` — and which addresses were left out and why. `alerts rules why <id> --ip <ip>` ends in a verdict such as "Detected at 15:06:31 but not notified: 203.0.113.50 was already blocked by instant rule X". `--yes` on `from-text` refuses to create a rule whose check matches your data right now (it would alert at once, usually a bad baseline); review the rows, then `--yes --force` if they are a real attack.
|
|
322
|
+
|
|
323
|
+
MCP parity for quiet rules: `securenow_alert_rule_runs` (run history), `securenow_alert_rule_run_now` (write, confirm) and `securenow_alert_rule_explain` (`ip`, `since`: verdict + findings).
|
|
315
324
|
|
|
316
325
|
MCP parity for noisy alert-rule reviews:
|
|
317
326
|
|
package/cli/client.js
CHANGED
|
@@ -56,13 +56,16 @@ function request(method, endpoint, { body, query, token, raw } = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
if (res.statusCode === 403) {
|
|
58
58
|
const msg = parsed?.error || parsed?.message || 'Access denied';
|
|
59
|
-
|
|
59
|
+
const err = new CLIError(`${msg}. Admin auth is connected, but this user/plan may lack permission for this control-plane operation. Runtime app connection is unrelated.`, 403);
|
|
60
|
+
if (parsed?.code) err.code = parsed.code;
|
|
61
|
+
reject(err);
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
62
64
|
if (res.statusCode >= 400) {
|
|
63
65
|
const msg = parsed?.error || parsed?.message || `Request failed (HTTP ${res.statusCode})`;
|
|
64
66
|
const details = parsed?.details || parsed?.unauthorizedKeys;
|
|
65
67
|
const err = new CLIError(details ? `${msg} — ${details}` : msg, res.statusCode);
|
|
68
|
+
if (parsed?.code) err.code = parsed.code;
|
|
66
69
|
reject(err);
|
|
67
70
|
return;
|
|
68
71
|
}
|
package/cli/security.js
CHANGED
|
@@ -33,6 +33,19 @@ function ruleStatusBadge(rule) {
|
|
|
33
33
|
return st;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function throttleLabel(throttle) {
|
|
37
|
+
return throttle?.enabled ? `one alert per address every ${throttle.minutes || 15} min` : 'off';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The API refuses edits (mode included) on system rules: they are read-only templates.
|
|
41
|
+
function isSystemRuleRefusal(err) {
|
|
42
|
+
return err?.code === 'system_rule_readonly' || (err?.statusCode === 403 && /read-only templates/i.test(err?.message || ''));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function printWarnings(data) {
|
|
46
|
+
for (const warning of data?.warnings || []) ui.warn(warning);
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
/** Dispatch: list | show <id> | update <id> ... */
|
|
37
50
|
async function alertRulesRoute(args, flags) {
|
|
38
51
|
const sub = args[0];
|
|
@@ -78,6 +91,15 @@ async function alertRulesRoute(args, flags) {
|
|
|
78
91
|
if (sub === 'exclusions') {
|
|
79
92
|
return alertRuleExclusions(args.slice(1), flags);
|
|
80
93
|
}
|
|
94
|
+
if (sub === 'runs') {
|
|
95
|
+
return alertRuleRuns(args.slice(1), flags);
|
|
96
|
+
}
|
|
97
|
+
if (sub === 'run' || sub === 'run-now') {
|
|
98
|
+
return alertRuleRunNow(args.slice(1), flags);
|
|
99
|
+
}
|
|
100
|
+
if (sub === 'why' || sub === 'explain') {
|
|
101
|
+
return alertRuleWhy(args.slice(1), flags);
|
|
102
|
+
}
|
|
81
103
|
if (sub === 'promote') {
|
|
82
104
|
return alertRuleSetMode(args.slice(1), flags, 'prod');
|
|
83
105
|
}
|
|
@@ -115,7 +137,9 @@ async function alertRuleSetMode(args, flags, mode) {
|
|
|
115
137
|
s.stop(`${ui.truncate(id, 12)} → ${mode}`);
|
|
116
138
|
okCount++;
|
|
117
139
|
} catch (err) {
|
|
118
|
-
s.fail(
|
|
140
|
+
s.fail(isSystemRuleRefusal(err)
|
|
141
|
+
? `${ui.truncate(id, 12)} is a system rule (a read-only template): duplicate it first: securenow alerts rules duplicate ${id}`
|
|
142
|
+
: `Failed on ${ui.truncate(id, 12)}: ${err.message}`);
|
|
119
143
|
failures.push(id);
|
|
120
144
|
}
|
|
121
145
|
}
|
|
@@ -284,6 +308,7 @@ async function alertRuleCreate(args, flags) {
|
|
|
284
308
|
}
|
|
285
309
|
if (flags['no-throttle']) body.throttle = { enabled: false, minutes: 0 };
|
|
286
310
|
else if (flags['throttle-minutes']) body.throttle = { enabled: true, minutes: Number(flags['throttle-minutes']) };
|
|
311
|
+
if (flags['allow-no-ip']) body.allowNoIp = true;
|
|
287
312
|
|
|
288
313
|
const channelStr = flags.channel || flags.channels;
|
|
289
314
|
if (channelStr) {
|
|
@@ -326,10 +351,12 @@ async function alertRuleCreate(args, flags) {
|
|
|
326
351
|
['Severity', r.severity || '-'],
|
|
327
352
|
['Applications', r.applicationsAll ? 'all apps' : ((r.applications || []).join(', ') || '-')],
|
|
328
353
|
['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '-')],
|
|
329
|
-
['Throttle', r.throttle
|
|
354
|
+
['Throttle', throttleLabel(r.throttle)],
|
|
330
355
|
['Query', r.queryMappingId?.name || r.queryMappingId || '-'],
|
|
331
356
|
['Validation', flags['no-validate'] ? 'skipped' : 'dry-run clean'],
|
|
357
|
+
...(data.firstRunQueued ? [['First run', `queued now (see: securenow alerts rules runs ${createdId})`]] : []),
|
|
332
358
|
]);
|
|
359
|
+
printWarnings(data);
|
|
333
360
|
console.log('');
|
|
334
361
|
ui.success(`Created. View it with: securenow alerts rules show ${createdId}`);
|
|
335
362
|
} catch (err) {
|
|
@@ -369,10 +396,12 @@ async function alertRuleShow(args, flags) {
|
|
|
369
396
|
['System rule', r.isSystem ? 'yes' : 'no'],
|
|
370
397
|
['Applications', appLine],
|
|
371
398
|
['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '—')],
|
|
372
|
-
['Throttle', r.throttle
|
|
399
|
+
['Throttle', throttleLabel(r.throttle)],
|
|
373
400
|
['Query', r.queryMappingId?.name || '—'],
|
|
374
401
|
]);
|
|
375
402
|
console.log('');
|
|
403
|
+
console.log(ui.c.dim(` Runs: securenow alerts rules runs ${r._id || r.id || id} Why no alert: securenow alerts rules why ${r._id || r.id || id} --ip <ip>`));
|
|
404
|
+
console.log('');
|
|
376
405
|
} catch (err) {
|
|
377
406
|
s.fail('Failed to fetch alert rule');
|
|
378
407
|
throw err;
|
|
@@ -457,6 +486,10 @@ async function alertRuleUpdate(args, flags) {
|
|
|
457
486
|
ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
|
|
458
487
|
} catch (err) {
|
|
459
488
|
s.fail('Failed to update alert rule');
|
|
489
|
+
if (isSystemRuleRefusal(err)) {
|
|
490
|
+
ui.info(`System rules are read-only templates. Duplicate it first: securenow alerts rules duplicate ${id}`);
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
460
493
|
throw err;
|
|
461
494
|
}
|
|
462
495
|
}
|
|
@@ -472,11 +505,13 @@ async function alertRuleSetSql(args, flags) {
|
|
|
472
505
|
const body = { sqlQuery };
|
|
473
506
|
if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
|
|
474
507
|
if (flags.category) body.category = flags.category;
|
|
508
|
+
if (flags['allow-no-ip']) body.allowNoIp = true;
|
|
475
509
|
|
|
476
510
|
const s = ui.spinner('Updating rule SQL');
|
|
477
511
|
try {
|
|
478
512
|
const data = await api.put(`/alert-rules/${id}`, body);
|
|
479
513
|
s.stop('Rule SQL updated');
|
|
514
|
+
printWarnings(data);
|
|
480
515
|
const ok = flags['no-validate'] ? null : await validateRuleById(id, flags);
|
|
481
516
|
if (ok && ok.status === 'failed') {
|
|
482
517
|
ui.warn(`Saved, but the new SQL failed a dry-run: ${ok.error || 'unknown error'}`);
|
|
@@ -783,14 +818,30 @@ async function alertRuleFromText(args, flags) {
|
|
|
783
818
|
}
|
|
784
819
|
s.stop('Draft ready');
|
|
785
820
|
|
|
821
|
+
// A draft that matches the data right now alerts the moment it is saved: a real ongoing attack,
|
|
822
|
+
// or (more often) a bad baseline. Unattended (--yes) creation stops there unless --force.
|
|
823
|
+
const matches = Number(result.check?.matches || 0);
|
|
824
|
+
const refuse = Boolean(flags.yes) && !flags.force && !flags['draft-only'] && matches > 0;
|
|
825
|
+
const refusal = `Not created: the check found ${matches} match${matches === 1 ? '' : 'es'} in your data right now, so this rule would alert as soon as it is saved.`;
|
|
826
|
+
|
|
786
827
|
if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
|
|
828
|
+
if (flags.json && refuse) {
|
|
829
|
+
ui.json({ ...result, created: false, refused: 'check_matches', message: refusal });
|
|
830
|
+
process.exit(1);
|
|
831
|
+
}
|
|
787
832
|
printDraft(result);
|
|
788
833
|
console.log('');
|
|
789
834
|
if (flags['draft-only']) {
|
|
790
835
|
ui.info('Draft only: nothing was created. Drop --draft-only to create it.');
|
|
791
836
|
return;
|
|
792
837
|
}
|
|
838
|
+
if (refuse) {
|
|
839
|
+
ui.warn(refusal);
|
|
840
|
+
ui.info('If those rows are a real attack, create it anyway with --yes --force. If they are normal traffic, refine the description and draft again.');
|
|
841
|
+
process.exit(1);
|
|
842
|
+
}
|
|
793
843
|
if (!flags.yes) {
|
|
844
|
+
if (matches > 0) ui.warn(`The check found ${matches} match${matches === 1 ? '' : 'es'} right now (above): this rule alerts as soon as it is saved.`);
|
|
794
845
|
const ok = await ui.confirm('Create this rule? It starts in test mode: it alerts, but blocks nothing');
|
|
795
846
|
if (!ok) { ui.info('Nothing created'); return; }
|
|
796
847
|
}
|
|
@@ -814,9 +865,12 @@ async function alertRuleFromText(args, flags) {
|
|
|
814
865
|
['Kind', r.executionMode === 'instant' ? 'instant (every request, as it arrives)' : `periodic (${r.schedule?.description || r.schedule?.cronExpression || '-'})`],
|
|
815
866
|
['Mode', `${r.mode || 'test'} (alerts only, no blocking)`],
|
|
816
867
|
['Application', (r.applications || []).join(', ') || '-'],
|
|
868
|
+
...(data.firstRunQueued ? [['First run', 'queued now']] : []),
|
|
817
869
|
]);
|
|
870
|
+
printWarnings(data);
|
|
818
871
|
console.log('');
|
|
819
872
|
ui.success(`Watching. Promote it when it has proved itself: securenow alerts rules promote ${id}`);
|
|
873
|
+
console.log(ui.c.dim(` Runs: securenow alerts rules runs ${id} Why no alert: securenow alerts rules why ${id} --ip <ip>`));
|
|
820
874
|
}
|
|
821
875
|
|
|
822
876
|
async function alertRuleTest(args, flags) {
|
|
@@ -824,6 +878,7 @@ async function alertRuleTest(args, flags) {
|
|
|
824
878
|
const id = args[0];
|
|
825
879
|
if (!id) {
|
|
826
880
|
ui.error('Usage: securenow alerts rules test <rule-id> [--app <key>] [--mode dry_run|live] [--wait]');
|
|
881
|
+
console.log(ui.c.dim(' dry_run (default) runs the query on your real data. live inserts 6 synthetic attack requests into your telemetry first.'));
|
|
827
882
|
process.exit(1);
|
|
828
883
|
}
|
|
829
884
|
|
|
@@ -1021,8 +1076,170 @@ async function alertRuleExclusions(args, flags) {
|
|
|
1021
1076
|
}
|
|
1022
1077
|
}
|
|
1023
1078
|
|
|
1079
|
+
// ── Rule runs: history, run now, why didn't this alert ──
|
|
1080
|
+
|
|
1081
|
+
const RUN_OUTCOME_COLORS = {
|
|
1082
|
+
notified: 'green',
|
|
1083
|
+
storm: 'red',
|
|
1084
|
+
error: 'red',
|
|
1085
|
+
throttled: 'yellow',
|
|
1086
|
+
suppressed_blocked: 'yellow',
|
|
1087
|
+
suppressed_trusted: 'yellow',
|
|
1088
|
+
no_ip: 'yellow',
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
function runOutcomeCell(outcome) {
|
|
1092
|
+
const color = RUN_OUTCOME_COLORS[outcome];
|
|
1093
|
+
return color && ui.c[color] ? ui.c[color](outcome) : ui.c.dim(outcome || '-');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function runAddressesCell(run) {
|
|
1097
|
+
const parts = [];
|
|
1098
|
+
if (run.notifiedIps?.length) parts.push(`notified ${run.notifiedIps.slice(0, 2).join(', ')}${run.notifiedIps.length > 2 ? '…' : ''}`);
|
|
1099
|
+
const left = run.suppressedIps || [];
|
|
1100
|
+
if (left.length) {
|
|
1101
|
+
const reasons = [...new Set(left.map((x) => x.reason))].join('/');
|
|
1102
|
+
parts.push(`${left.length} ${reasons}`);
|
|
1103
|
+
}
|
|
1104
|
+
if (!parts.length && run.ips?.length) parts.push(run.ips.slice(0, 2).join(', ') + (run.ips.length > 2 ? '…' : ''));
|
|
1105
|
+
return parts.join('; ') || '—';
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function alertRuleRuns(args, flags) {
|
|
1109
|
+
requireAuth();
|
|
1110
|
+
const id = args[0];
|
|
1111
|
+
if (!id) {
|
|
1112
|
+
ui.error('Usage: securenow alerts rules runs <rule-id> [--limit 20] [--since 1h]');
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
const query = { limit: flags.limit || 20 };
|
|
1116
|
+
if (flags.since) query.since = flags.since;
|
|
1117
|
+
const s = ui.spinner('Fetching rule runs');
|
|
1118
|
+
let data;
|
|
1119
|
+
try {
|
|
1120
|
+
data = await api.get(`/alert-rules/${id}/runs`, { query });
|
|
1121
|
+
s.stop(`Found ${(data.runs || []).length} run${(data.runs || []).length === 1 ? '' : 's'}`);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
s.fail('Failed to fetch rule runs');
|
|
1124
|
+
throw err;
|
|
1125
|
+
}
|
|
1126
|
+
if (flags.json) { ui.json(data); return; }
|
|
1127
|
+
|
|
1128
|
+
const runs = data.runs || [];
|
|
1129
|
+
console.log('');
|
|
1130
|
+
ui.heading(data.ruleName || 'Rule runs');
|
|
1131
|
+
if (data.nextRunAt) console.log(ui.c.dim(` Next scheduled run: ${new Date(data.nextRunAt).toISOString().replace('T', ' ').slice(0, 19)} UTC`));
|
|
1132
|
+
console.log('');
|
|
1133
|
+
if (runs.length === 0) {
|
|
1134
|
+
ui.info(data.executionMode === 'instant'
|
|
1135
|
+
? 'No matches recorded. An instant rule records a run when a request matches its conditions.'
|
|
1136
|
+
: `No runs recorded. Run it now: securenow alerts rules run ${id}`);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
ui.table(['Started (UTC)', 'Trigger', 'Outcome', 'Rows', 'Addresses', 'Detail'], runs.map((run) => [
|
|
1140
|
+
new Date(run.startedAt).toISOString().replace('T', ' ').slice(0, 19),
|
|
1141
|
+
run.trigger,
|
|
1142
|
+
runOutcomeCell(run.outcome),
|
|
1143
|
+
String(run.rows ?? 0),
|
|
1144
|
+
ui.truncate(runAddressesCell(run), 44),
|
|
1145
|
+
ui.truncate(run.error || run.detail || (run.notificationId ? `notification ${run.notificationId}` : ''), 60),
|
|
1146
|
+
]));
|
|
1147
|
+
console.log('');
|
|
1148
|
+
console.log(ui.c.dim(` Why no alert for an address: securenow alerts rules why ${id} --ip <ip> [--since 1h]`));
|
|
1149
|
+
console.log('');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function alertRuleRunNow(args, flags) {
|
|
1153
|
+
requireAuth();
|
|
1154
|
+
const id = args[0];
|
|
1155
|
+
if (!id) {
|
|
1156
|
+
ui.error('Usage: securenow alerts rules run <rule-id> [--no-wait] [--json]');
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
const requestedAt = Date.now();
|
|
1160
|
+
const s = ui.spinner('Queueing a run');
|
|
1161
|
+
let queued;
|
|
1162
|
+
try {
|
|
1163
|
+
queued = await api.post(`/alert-rules/${id}/run`, {});
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
s.fail('Failed to run the rule');
|
|
1166
|
+
throw err;
|
|
1167
|
+
}
|
|
1168
|
+
if (flags['no-wait']) {
|
|
1169
|
+
s.stop(queued.message || 'Queued');
|
|
1170
|
+
if (flags.json) ui.json(queued);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
s.update('Waiting for the run to finish');
|
|
1175
|
+
let run = null;
|
|
1176
|
+
for (let i = 0; i < 30 && !run; i++) {
|
|
1177
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1178
|
+
const data = await api.get(`/alert-rules/${id}/runs`, { query: { limit: 5 } });
|
|
1179
|
+
run = (data.runs || []).find((r) => r.trigger === 'manual' && new Date(r.startedAt).getTime() >= requestedAt - 15000) || null;
|
|
1180
|
+
}
|
|
1181
|
+
if (!run) {
|
|
1182
|
+
s.stop('Queued; the run has not finished yet');
|
|
1183
|
+
ui.info(`Check later: securenow alerts rules runs ${id}`);
|
|
1184
|
+
if (flags.json) ui.json({ ...queued, run: null });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
s.stop(`Run finished: ${run.outcome}`);
|
|
1188
|
+
if (flags.json) { ui.json({ ...queued, run }); return; }
|
|
1189
|
+
console.log('');
|
|
1190
|
+
ui.keyValue([
|
|
1191
|
+
['Outcome', runOutcomeCell(run.outcome)],
|
|
1192
|
+
['Rows', String(run.rows ?? 0)],
|
|
1193
|
+
['Addresses', String(run.uniqueIps ?? 0)],
|
|
1194
|
+
['Notified', (run.notifiedIps || []).join(', ') || '—'],
|
|
1195
|
+
['Left out', (run.suppressedIps || []).map((x) => `${x.ip} (${x.reason})`).join(', ') || '—'],
|
|
1196
|
+
['Notification', run.notificationId || '—'],
|
|
1197
|
+
['Duration', `${run.durationMs} ms`],
|
|
1198
|
+
...(run.detail ? [['Detail', run.detail]] : []),
|
|
1199
|
+
...(run.error ? [['Error', run.error]] : []),
|
|
1200
|
+
]);
|
|
1201
|
+
console.log('');
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function alertRuleWhy(args, flags) {
|
|
1205
|
+
requireAuth();
|
|
1206
|
+
const id = args[0];
|
|
1207
|
+
const ip = flags.ip || args[1];
|
|
1208
|
+
if (!id || !ip || ip === true) {
|
|
1209
|
+
ui.error('Usage: securenow alerts rules why <rule-id> --ip <ip> [--since 1h|30m|2d|<iso>] [--json]');
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
const query = { ip: String(ip), since: flags.since || '1h' };
|
|
1213
|
+
const s = ui.spinner(`Checking what the rule did with ${ip}`);
|
|
1214
|
+
let data;
|
|
1215
|
+
try {
|
|
1216
|
+
data = await api.get(`/alert-rules/${id}/explain`, { query });
|
|
1217
|
+
s.stop('');
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
s.fail('Failed to explain the rule');
|
|
1220
|
+
throw err;
|
|
1221
|
+
}
|
|
1222
|
+
if (flags.json) { ui.json(data); return; }
|
|
1223
|
+
console.log('');
|
|
1224
|
+
console.log(` ${ui.c.bold(data.verdict || '')}`);
|
|
1225
|
+
console.log('');
|
|
1226
|
+
for (const finding of data.findings || []) {
|
|
1227
|
+
console.log(` • ${finding.title}`);
|
|
1228
|
+
if (finding.detail) console.log(ui.c.dim(` ${finding.detail}`));
|
|
1229
|
+
}
|
|
1230
|
+
console.log('');
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1024
1233
|
// ── Alert Channels ──
|
|
1025
1234
|
|
|
1235
|
+
function channelDestination(ch) {
|
|
1236
|
+
if (ch.type === 'email') return ch.email || '—';
|
|
1237
|
+
if (ch.type === 'securenow') return 'in-app (SecureNow inbox)';
|
|
1238
|
+
if (ch.type === 'slack') return 'Slack webhook (URL hidden)';
|
|
1239
|
+
if (ch.type === 'webhook') return `${ch.webhookMethod || 'POST'} webhook (URL hidden)`;
|
|
1240
|
+
return '—';
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1026
1243
|
async function alertChannelsList(args, flags) {
|
|
1027
1244
|
requireAuth();
|
|
1028
1245
|
const s = ui.spinner('Fetching alert channels');
|
|
@@ -1038,10 +1255,10 @@ async function alertChannelsList(args, flags) {
|
|
|
1038
1255
|
ui.c.dim(ch.id || ch._id || ''),
|
|
1039
1256
|
ch.name || '—',
|
|
1040
1257
|
ch.type || '—',
|
|
1041
|
-
ui.statusBadge(ch.
|
|
1042
|
-
ch
|
|
1258
|
+
ui.statusBadge(ch.status === 'Disabled' ? 'disabled' : 'active'),
|
|
1259
|
+
channelDestination(ch),
|
|
1043
1260
|
]);
|
|
1044
|
-
ui.table(['ID', 'Name', 'Type', 'Status', '
|
|
1261
|
+
ui.table(['ID', 'Name', 'Type', 'Status', 'Destination'], rows);
|
|
1045
1262
|
console.log('');
|
|
1046
1263
|
} catch (err) {
|
|
1047
1264
|
s.fail('Failed to fetch alert channels');
|
|
@@ -1056,22 +1273,27 @@ async function alertHistoryList(args, flags) {
|
|
|
1056
1273
|
const s = ui.spinner('Fetching alert history');
|
|
1057
1274
|
try {
|
|
1058
1275
|
const query = { limit: flags.limit || 20 };
|
|
1276
|
+
if (flags.page) query.page = flags.page;
|
|
1059
1277
|
const data = await api.get('/alert-history', { query });
|
|
1060
|
-
const history = data.alerts || [];
|
|
1061
|
-
|
|
1278
|
+
const history = data.items || data.alerts || [];
|
|
1279
|
+
const total = data.pagination?.total ?? data.totalItems;
|
|
1280
|
+
s.stop(`Found ${history.length} alert${history.length !== 1 ? 's' : ''}${total ? ` (${total} total)` : ''}`);
|
|
1062
1281
|
|
|
1063
1282
|
if (flags.json) { ui.json(data); return; }
|
|
1064
1283
|
|
|
1065
1284
|
console.log('');
|
|
1066
1285
|
const rows = history.map(h => [
|
|
1067
1286
|
ui.c.dim(h.id || h._id || ''),
|
|
1068
|
-
h.
|
|
1069
|
-
h.
|
|
1070
|
-
|
|
1071
|
-
h.
|
|
1072
|
-
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),
|
|
1073
1292
|
]);
|
|
1074
|
-
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
|
+
}
|
|
1075
1297
|
console.log('');
|
|
1076
1298
|
} catch (err) {
|
|
1077
1299
|
s.fail('Failed to fetch alert history');
|
package/cli.js
CHANGED
|
@@ -311,9 +311,12 @@ const COMMANDS = {
|
|
|
311
311
|
usage: 'securenow alerts <subcommand> [options]',
|
|
312
312
|
sub: {
|
|
313
313
|
rules: {
|
|
314
|
-
desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, or tune alert rules',
|
|
315
|
-
usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions|versions|version|rollback> [options]\n'
|
|
316
|
-
+ ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]'
|
|
314
|
+
desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, run, explain, or tune alert rules',
|
|
315
|
+
usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|run|runs|why|dry-run-query|tune-query|exclusions|versions|version|rollback> [options]\n'
|
|
316
|
+
+ ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]\n'
|
|
317
|
+
+ ' securenow alerts rules runs <id> [--limit 20] [--since 1h] what each run saw and why it did or didn\'t notify\n'
|
|
318
|
+
+ ' securenow alerts rules run <id> [--no-wait] run a periodic rule now\n'
|
|
319
|
+
+ ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address',
|
|
317
320
|
flags: {
|
|
318
321
|
json: 'Output as JSON',
|
|
319
322
|
env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
|
|
@@ -329,8 +332,13 @@ const COMMANDS = {
|
|
|
329
332
|
category: 'With create: query category (default: custom)',
|
|
330
333
|
severity: 'With create: critical | high | medium | low',
|
|
331
334
|
schedule: 'With create: cron expression (default */15 * * * *)',
|
|
332
|
-
'throttle-minutes': 'With create:
|
|
335
|
+
'throttle-minutes': 'With create: one alert per address every N minutes (default 15)',
|
|
333
336
|
'no-throttle': 'With create: disable throttling',
|
|
337
|
+
'allow-no-ip': 'With create/set-sql: save SQL that returns no ip column (it will never notify)',
|
|
338
|
+
ip: 'With why: the address to explain',
|
|
339
|
+
since: 'With runs/why: window, e.g. 30m, 1h, 2d or an ISO date (why defaults to 1h)',
|
|
340
|
+
limit: 'With runs: how many runs to show (default 20)',
|
|
341
|
+
'no-wait': 'With run: queue the run and return without waiting for it',
|
|
334
342
|
'execution-mode': 'With create: scheduled | instant | hybrid',
|
|
335
343
|
channel: 'With create: comma-separated alert channel ids (default: in-app)',
|
|
336
344
|
'query-mapping-id': 'With create: reuse an existing saved query instead of --sql',
|
|
@@ -338,7 +346,7 @@ const COMMANDS = {
|
|
|
338
346
|
'no-applications-all': 'With update: scope to explicit --apps list',
|
|
339
347
|
apps: 'Comma-separated app keys (with create/update)',
|
|
340
348
|
app: 'Application key for rule tests',
|
|
341
|
-
mode: 'With test: dry_run | live. With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
|
|
349
|
+
mode: 'With test: dry_run (default) | live (inserts 6 synthetic attack requests). With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
|
|
342
350
|
status: 'With update: Active | Disabled | Paused. With list: filter by status',
|
|
343
351
|
enable: 'With update: shortcut for --status Active',
|
|
344
352
|
disable: 'With update: shortcut for --status Disabled',
|
|
@@ -358,7 +366,7 @@ const COMMANDS = {
|
|
|
358
366
|
notification: 'Review notification id tied to tuning',
|
|
359
367
|
note: 'Review note tied to tuning',
|
|
360
368
|
yes: 'Confirm write prompts',
|
|
361
|
-
force: 'Confirm write prompts',
|
|
369
|
+
force: 'Confirm write prompts. With from-text --yes: create even when the check matches data right now',
|
|
362
370
|
},
|
|
363
371
|
run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
|
|
364
372
|
},
|
package/mcp/catalog.js
CHANGED
|
@@ -1419,13 +1419,13 @@ const TOOLS = [
|
|
|
1419
1419
|
{
|
|
1420
1420
|
name: 'securenow_alert_rule_create',
|
|
1421
1421
|
title: 'Create Alert Rule (Test Mode)',
|
|
1422
|
-
description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (
|
|
1422
|
+
description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (the API refuses SQL with no ip column, since it would never alert, unless allowNoIp). A periodic rule runs once right away (firstRunQueued); see securenow_alert_rule_runs. Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
|
|
1423
1423
|
scope: 'alerts:write',
|
|
1424
1424
|
readOnly: false,
|
|
1425
1425
|
confirm: true,
|
|
1426
1426
|
method: 'POST',
|
|
1427
1427
|
endpoint: '/alert-rules',
|
|
1428
|
-
bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle'],
|
|
1428
|
+
bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle', 'allowNoIp'],
|
|
1429
1429
|
inputSchema: objectSchema({
|
|
1430
1430
|
name: string('Rule name.'),
|
|
1431
1431
|
description: string('What it catches, in a sentence.'),
|
|
@@ -1438,7 +1438,8 @@ const TOOLS = [
|
|
|
1438
1438
|
sqlQuery: string('Periodic rules: the detection SELECT.'),
|
|
1439
1439
|
schedule: { type: 'object', additionalProperties: true, description: 'Periodic rules: {cronExpression: "* * * * *", description: "Every minute"}. Default every 15 minutes.' },
|
|
1440
1440
|
instant: { type: 'object', additionalProperties: true, description: 'Instant rules: {matchMode: "any" | "all", conditions: [{field, operator, value}], environments: ["staging"] (optional; omit to watch every environment)}.' },
|
|
1441
|
-
throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per
|
|
1441
|
+
throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per address every N minutes (other addresses still alert).' },
|
|
1442
|
+
allowNoIp: boolean('Periodic rules: save SQL that returns no ip column anyway (it will never notify). Default false.'),
|
|
1442
1443
|
...confirmSchema,
|
|
1443
1444
|
}, ['name', 'confirm', 'reason']),
|
|
1444
1445
|
},
|
|
@@ -1536,9 +1537,9 @@ const TOOLS = [
|
|
|
1536
1537
|
applicationsAll: boolean('Scope rule to all applications.'),
|
|
1537
1538
|
applications: arrayOfStrings('Application keys when applicationsAll is false.'),
|
|
1538
1539
|
schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
|
|
1539
|
-
throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch.' },
|
|
1540
|
+
throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch {enabled, minutes}: one alert per address every N minutes.' },
|
|
1540
1541
|
alertChannelIds: arrayOfStrings('Alert channel ids.'),
|
|
1541
|
-
reviewAction: string('Rule review action
|
|
1542
|
+
reviewAction: string('Rule review action: keep_active (keep it running and stop auto-pausing it) or tuned_version (the rule was tuned; resume it).'),
|
|
1542
1543
|
reviewNote: string('Review note.'),
|
|
1543
1544
|
reviewNotificationId: string('Notification id tied to this review.'),
|
|
1544
1545
|
...confirmSchema,
|
|
@@ -1832,7 +1833,7 @@ const TOOLS = [
|
|
|
1832
1833
|
{
|
|
1833
1834
|
name: 'securenow_alert_rule_test',
|
|
1834
1835
|
title: 'Test Alert Rule',
|
|
1835
|
-
description: 'Start an alert rule test.
|
|
1836
|
+
description: 'Start an alert rule test. By default (mode "dry_run") it runs the rule\'s query once on the app\'s real data and inserts nothing. Mode "live" first inserts 6 synthetic attack requests into the app\'s telemetry; only ask for it explicitly.',
|
|
1836
1837
|
scope: 'alerts:write',
|
|
1837
1838
|
readOnly: false,
|
|
1838
1839
|
confirm: true,
|
|
@@ -1843,7 +1844,7 @@ const TOOLS = [
|
|
|
1843
1844
|
inputSchema: objectSchema({
|
|
1844
1845
|
id: string('Alert rule id.'),
|
|
1845
1846
|
applicationKey: string('Application key to test.'),
|
|
1846
|
-
mode: string('dry_run (read-only
|
|
1847
|
+
mode: string('dry_run (default, read-only) or live (inserts 6 synthetic attack requests).'),
|
|
1847
1848
|
...confirmSchema,
|
|
1848
1849
|
}, ['id', 'confirm', 'reason']),
|
|
1849
1850
|
},
|
|
@@ -1880,6 +1881,53 @@ const TOOLS = [
|
|
|
1880
1881
|
testId: string('Alert rule test id.'),
|
|
1881
1882
|
}, ['id', 'testId']),
|
|
1882
1883
|
},
|
|
1884
|
+
{
|
|
1885
|
+
name: 'securenow_alert_rule_runs',
|
|
1886
|
+
title: 'List Alert Rule Runs',
|
|
1887
|
+
description: 'A rule\'s recent runs, newest first: when it ran (schedule, manual, create or instant trigger), what it returned (rows, addresses), and the outcome: notified, no_rows, throttled (address alerted on within the throttle window), suppressed_blocked (already blocked), suppressed_trusted, no_ip (rows without an address), storm, auto_resolved (dismissed by an exclusion, trusted IP or verified bot), skipped or error. suppressedIps says which addresses were left out and why. Also nextRunAt for periodic rules. Quiet runs are kept 2 days, the rest 14.',
|
|
1888
|
+
scope: 'alerts:read',
|
|
1889
|
+
readOnly: true,
|
|
1890
|
+
method: 'GET',
|
|
1891
|
+
endpoint: '/alert-rules/:id/runs',
|
|
1892
|
+
pathParams: ['id'],
|
|
1893
|
+
queryFields: ['limit', 'since'],
|
|
1894
|
+
inputSchema: objectSchema({
|
|
1895
|
+
id: string('Alert rule id.'),
|
|
1896
|
+
limit: number('How many runs (default 50, max 200).', { minimum: 1, maximum: 200 }),
|
|
1897
|
+
since: string('Only runs since then: 30m, 1h, 2d or an ISO date.'),
|
|
1898
|
+
}, ['id']),
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
name: 'securenow_alert_rule_run_now',
|
|
1902
|
+
title: 'Run Alert Rule Now',
|
|
1903
|
+
description: 'Run a periodic (scheduled) rule once now, outside its schedule. It can notify like a scheduled run (test-mode rules alert only). The run appears in securenow_alert_rule_runs within seconds with trigger "manual". Instant rules have nothing to run (they check every request); use securenow_alert_rule_explain instead. Write action; requires confirmation.',
|
|
1904
|
+
scope: 'alerts:write',
|
|
1905
|
+
readOnly: false,
|
|
1906
|
+
confirm: true,
|
|
1907
|
+
method: 'POST',
|
|
1908
|
+
endpoint: '/alert-rules/:id/run',
|
|
1909
|
+
pathParams: ['id'],
|
|
1910
|
+
inputSchema: objectSchema({
|
|
1911
|
+
id: string('Alert rule id (an active periodic rule).'),
|
|
1912
|
+
...confirmSchema,
|
|
1913
|
+
}, ['id', 'confirm', 'reason']),
|
|
1914
|
+
},
|
|
1915
|
+
{
|
|
1916
|
+
name: 'securenow_alert_rule_explain',
|
|
1917
|
+
title: 'Explain Why a Rule Did or Didn\'t Alert',
|
|
1918
|
+
description: 'Why did (or didn\'t) this rule alert on this address? Checks, in a time window: the rule\'s status, mode, apps and environments; its runs and what each did with the address (notified, throttled, already blocked, trusted, dismissed); the per-address throttle; blocks on the address and the rule that made them; trusted and allowlist entries; notifications; and the address\'s requests, replayed through an instant rule\'s conditions. Returns ordered findings and a one-line verdict, e.g. "Detected at 15:06:31 but not notified: 203.0.113.50 was already blocked by instant rule X".',
|
|
1919
|
+
scope: 'alerts:read',
|
|
1920
|
+
readOnly: true,
|
|
1921
|
+
method: 'GET',
|
|
1922
|
+
endpoint: '/alert-rules/:id/explain',
|
|
1923
|
+
pathParams: ['id'],
|
|
1924
|
+
queryFields: ['ip', 'since'],
|
|
1925
|
+
inputSchema: objectSchema({
|
|
1926
|
+
id: string('Alert rule id.'),
|
|
1927
|
+
ip: string('The address to explain (IPv4 or IPv6).'),
|
|
1928
|
+
since: string('Window start: 30m, 1h (default), 2d or an ISO date; at most 7 days.'),
|
|
1929
|
+
}, ['id', 'ip']),
|
|
1930
|
+
},
|
|
1883
1931
|
{
|
|
1884
1932
|
name: 'securenow_alert_rule_exclusions_list',
|
|
1885
1933
|
title: 'List Alert Rule Exclusions',
|
package/package.json
CHANGED