securenow 8.17.0 → 8.19.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/README.md +4 -1
- package/SKILL-CLI.md +75 -1
- package/cli/human.js +112 -1
- package/cli/monitor.js +43 -0
- package/cli/review.js +295 -0
- package/cli/security.js +121 -2
- package/cli.js +51 -10
- package/esm-detect.js +57 -0
- package/mcp/catalog.js +133 -20
- package/mcp/server.js +17 -2
- package/package.json +3 -2
- package/register.js +3 -7
- package/tracing.js +7 -13
package/README.md
CHANGED
|
@@ -174,8 +174,11 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
|
|
|
174
174
|
|
|
175
175
|
| Command | Description |
|
|
176
176
|
|---|---|
|
|
177
|
-
| `securenow
|
|
177
|
+
| `securenow review` / `review show <row>` | Alert Review: rules that fired too wide and paused themselves |
|
|
178
|
+
| `securenow review group <row> --outcome rule_tuning --reason "..." --yes` | Close every run of a group after fixing its rule |
|
|
179
|
+
| `securenow human list` / `human show <row>` | Requires Human: AI-prepared decisions awaiting a human |
|
|
178
180
|
| `securenow human block <row> --yes --reason "..."` | Approve a proposed block |
|
|
181
|
+
| `securenow human close <row> --outcome deferred --reason "..." --yes` | Close a row without blocking |
|
|
179
182
|
| `securenow blocklist add <ip> [--route /admin*] [--mode prefix] [--duration 24h] [--reason "..."]` | Block an IP globally or per-route |
|
|
180
183
|
| `securenow blocklist unblock <id> --reason "..."` | Stop enforcement, keep the history |
|
|
181
184
|
| `securenow ratelimit from-text "5 per minute per IP on POST /api/login for 2h" --yes` | Rate-limit rule from natural language |
|
package/SKILL-CLI.md
CHANGED
|
@@ -28,6 +28,22 @@ npx -p securenow securenow-mcp
|
|
|
28
28
|
|
|
29
29
|
The MCP server reads admin/control-plane auth from `.securenow/admin.json` and SDK runtime app credentials from `.securenow/runtime.json`, with legacy `.securenow/credentials.json` still supported. Admin/global tools use admin auth; runtime-scoped read tools can use the runtime API key where its scopes allow it. Write tools require `confirm:true` plus a reason.
|
|
30
30
|
|
|
31
|
+
**For agents, prefer the hosted MCP** (`https://api.securenow.ai/mcp`, OAuth sign-in, no install and always the current tools). With the local stdio server, warm the package once so the first connection doesn't time out on the download (`npx -y securenow@latest --version`), and give the client a longer connect timeout (Claude Code: `MCP_TIMEOUT=120000`).
|
|
32
|
+
|
|
33
|
+
**Tool profiles** (stdio server): `securenow mcp --profile <name>` (or `SECURENOW_MCP_PROFILE=<name>`) serves only the tools one job needs, so the agent carries less context. Every profile keeps `securenow_auth_status`, `securenow_apps_list` and `securenow_apps_info`; calling a tool outside the profile returns an error naming the profile.
|
|
34
|
+
|
|
35
|
+
| Profile | Tools | What's in it |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `all` (default) | 112 | everything |
|
|
38
|
+
| `investigator` | 58 | every read-only tool, plus forensics question/chat/confirm |
|
|
39
|
+
| `detection` | 47 | `securenow_alert_rule*` (draft, create, test, run, runs, explain, versions, exclusions, request promotion…), traces, logs, forensics, IP intelligence, notifications list/get |
|
|
40
|
+
| `responder` | 77 | notifications, human queue, alert reviews, blocklist, allowlist, trusted IPs, rate limits, challenges, automations, firewall, IP intelligence, plus rule get/runs/explain and traces/logs for context |
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add securenow-investigator -- npx -y securenow@latest mcp --profile investigator
|
|
44
|
+
codex mcp add securenow-detection -- npx securenow mcp --profile detection
|
|
45
|
+
```
|
|
46
|
+
|
|
31
47
|
### Authenticate
|
|
32
48
|
|
|
33
49
|
```bash
|
|
@@ -213,8 +229,11 @@ securenow traces analyze <traceId> --env production # AI-powered trace analysi
|
|
|
213
229
|
securenow logs [--app <key>] [--env production] [--limit N] [--minutes M] [--level error|warn|info]
|
|
214
230
|
securenow logs list --app my-app --env production --minutes 30 --level error
|
|
215
231
|
securenow logs trace <traceId> --env production # logs correlated to a specific trace
|
|
232
|
+
securenow logs errors --app my-app --since 6h [--warn] # what is failing: error lines grouped by message (ids/numbers normalized), counts, first/last seen, sample traces
|
|
216
233
|
```
|
|
217
234
|
|
|
235
|
+
For "what's broken right now?", start with `logs errors` (MCP `securenow_logs_errors`): one call returns the distinct failures, not hundreds of raw lines.
|
|
236
|
+
|
|
218
237
|
### Notifications
|
|
219
238
|
|
|
220
239
|
```bash
|
|
@@ -225,7 +244,41 @@ securenow notifications read-all # mark all as read
|
|
|
225
244
|
securenow notifications unread # unread count
|
|
226
245
|
```
|
|
227
246
|
|
|
228
|
-
###
|
|
247
|
+
### Two review queues
|
|
248
|
+
|
|
249
|
+
SecureNow decides most things alone (instant blocks, automations, IP investigations, self-pausing
|
|
250
|
+
rules). What it won't decide alone waits in two queues. Work **Alert Review** first: one rule fix
|
|
251
|
+
closes every run it caused and the Requires Human rows that came from the same noise.
|
|
252
|
+
|
|
253
|
+
| Queue | Question | Decisions | CLI |
|
|
254
|
+
|---|---|---|---|
|
|
255
|
+
| Alert Review | "Is this rule right?" A rule fired far too wide, or on an IP treated as your own infrastructure; it usually pauses itself | tune the rule, keep it on, close/dismiss with a reason (never blocks or unblocks) | `securenow review` |
|
|
256
|
+
| Requires Human | "What do we do about this IP or plan?" An investigated IP with a verdict SecureNow won't act on alone, or a proposed plan | block, false positive, close without blocking, approve/reject a plan | `securenow human` |
|
|
257
|
+
|
|
258
|
+
### Alert Review — is this rule right?
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
securenow review # open runs grouped by root cause (rule + route + evidence shape)
|
|
262
|
+
securenow review list --search lab-auth # one app (name or key), rule, route or IP
|
|
263
|
+
securenow review show 1 # group 1: rule, route, IPs, proposed guard, its runs
|
|
264
|
+
securenow review show <notificationId> # one run
|
|
265
|
+
securenow review keep <notificationId> --reason "Correct detection" --yes # turn the rule back on, stop it auto-pausing, close the run
|
|
266
|
+
securenow review close <notificationId> --outcome rule_tuning --reason "..." --yes
|
|
267
|
+
securenow review dismiss <notificationId> --reason "Not worth acting on" --yes
|
|
268
|
+
securenow review group 1 --outcome rule_tuning --reason "Guard added in v4" --preview # what would close
|
|
269
|
+
securenow review group 1 --outcome rule_tuning --reason "Guard added in v4" --yes # close the whole group
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Outcomes: `rule_tuning`, `false_positive`, `clean`, `deferred`, `new_alert_rule`, `rate_limited`,
|
|
273
|
+
`ambiguous`, `skipped`, `other`. Closing writes a decision report on the alert and changes no
|
|
274
|
+
enforcement. Tune the rule first (`securenow alerts rules change <id> "..."`), then close its group.
|
|
275
|
+
A mixed group (rows disagree on verdict or sender) needs `--allow-mixed` after reviewing every row.
|
|
276
|
+
|
|
277
|
+
MCP parity: `securenow_alert_review_runs_list`, `securenow_alert_review_run_get`,
|
|
278
|
+
`securenow_alert_review_run_keep_alive`, `securenow_alert_review_run_resolve`,
|
|
279
|
+
`securenow_alert_review_run_dismiss`, `securenow_alert_review_run_apply_group_decision`; prompt `work_alert_review`.
|
|
280
|
+
|
|
281
|
+
### Requires Human — what do we do about this IP or plan?
|
|
229
282
|
|
|
230
283
|
Use this for the same work shown in **Requires Human**: AI has already grouped alerts, fetched traces, built a report, and left a final Block IP or False Positive decision.
|
|
231
284
|
|
|
@@ -235,6 +288,7 @@ securenow human list --limit 20
|
|
|
235
288
|
securenow human show 1 # inspect row 1 with AI report, investigation steps, proofs, trace links
|
|
236
289
|
securenow human block 1 --yes --reason "AI evidence confirmed malicious"
|
|
237
290
|
securenow human fp 1 --yes --reason "Scoped false positive after evidence review"
|
|
291
|
+
securenow human close 1 --outcome deferred --reason "Scanner, nothing reachable; not worth a block" --yes # leaves the queue, nothing blocked
|
|
238
292
|
securenow human action 1 --status rejected --yes --reason "Tuning guard is too broad"
|
|
239
293
|
securenow human prompt 1 # print a Codex/Claude MCP prompt for this row
|
|
240
294
|
securenow human work --limit 10 # list queue + print MCP runbook for deep queue work
|
|
@@ -247,9 +301,26 @@ MCP parity:
|
|
|
247
301
|
- `securenow_human_action_report`
|
|
248
302
|
- `securenow_human_action_block`
|
|
249
303
|
- `securenow_human_action_false_positive`
|
|
304
|
+
- `securenow_human_action_close_no_write`
|
|
250
305
|
- `securenow_human_case_action_update`
|
|
251
306
|
- prompts: `investigate_human_action_row`, `work_human_actions`
|
|
252
307
|
|
|
308
|
+
`human report` only adds a note: the row stays. `human close` removes it without enforcement.
|
|
309
|
+
Approving a `tune_rule` or `create_exclusion` plan with `human action` records the decision and
|
|
310
|
+
changes nothing: change the rule first, then approve. Only `approve_block` blocks.
|
|
311
|
+
|
|
312
|
+
### Run the queues with your own agent
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
claude "$(npx -y securenow@latest agent prompt patrol)" # read-only pass: a plan
|
|
316
|
+
claude -p "$(npx -y securenow@latest agent prompt patrol --search my-app --confirm-writes)" --allowedTools "mcp__securenow" # unattended, acts on one app
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`soc_patrol` works Alert Review then Requires Human, up to 10 groups and 10 rows per pass
|
|
320
|
+
(`--max-alert-review-groups`, `--max-human-rows`). The API key's scopes are the hard limit: a
|
|
321
|
+
Read Only key cannot change anything; acting needs `notifications:write`, `alerts:write` and
|
|
322
|
+
`rate_limits:write`. Setup for Claude Code, Codex and Cursor: dashboard → Run With Your Agent.
|
|
323
|
+
|
|
253
324
|
Write tools still require `confirm:true` plus a reason. False positives should stay restrictive to the app, alert rule, path, method/status, user-agent, body pattern, or other exact evidence the AI report supports. Notification IP investigations can include `metadata.matchedSubdetectors`; prefer that exact field over inferring which subdetector fired from the title or sample body.
|
|
254
325
|
|
|
255
326
|
### Alerts
|
|
@@ -295,6 +366,7 @@ securenow alerts rules delete <id> --yes # delete a custom rule (system rules
|
|
|
295
366
|
securenow alerts rules update <id> --applications-all # all current & future apps
|
|
296
367
|
securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
|
|
297
368
|
securenow alerts rules test <id> --wait # dry-run an existing rule's query (default; --mode live inserts 6 synthetic attack requests)
|
|
369
|
+
securenow alerts rules change <id> "raise the threshold to 20" # rewrite a rule from English: checked, shown before/after, applied as a new version
|
|
298
370
|
securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
|
|
299
371
|
securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
|
|
300
372
|
securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
|
|
@@ -322,6 +394,8 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
|
|
|
322
394
|
|
|
323
395
|
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).
|
|
324
396
|
|
|
397
|
+
**Agents don't promote rules; they ask.** When a test-mode rule has proved itself, an agent runs `securenow alerts rules request-promotion <id> --reason "..." --evidence "..."` (MCP `securenow_alert_rule_request_promotion`). A person sees it on the rules page or with `securenow human promotions` and runs `securenow human approve-promotion <request-id> --yes` (the rule goes to prod, recorded as a rule version naming the approver) or `deny-promotion <request-id> --reason "..."`. API keys and MCP tokens can ask but not decide.
|
|
398
|
+
|
|
325
399
|
MCP parity for noisy alert-rule reviews:
|
|
326
400
|
|
|
327
401
|
- `securenow_alert_rule_get`
|
package/cli/human.js
CHANGED
|
@@ -509,13 +509,60 @@ async function report(args, flags) {
|
|
|
509
509
|
}
|
|
510
510
|
s.stop('Decision report recorded');
|
|
511
511
|
if (flags.json) ui.json(data);
|
|
512
|
-
else
|
|
512
|
+
else {
|
|
513
|
+
ui.success(`Recorded decision report: ${decisionReport.outcome}`);
|
|
514
|
+
if (task.ip) ui.info(`A report is a note: the row stays in the queue. To close it without enforcement: securenow human close ${task.notificationId}:${task.ip} --outcome ${decisionReport.outcome}`);
|
|
515
|
+
}
|
|
513
516
|
} catch (err) {
|
|
514
517
|
s.fail('Failed to record decision report');
|
|
515
518
|
throw err;
|
|
516
519
|
}
|
|
517
520
|
}
|
|
518
521
|
|
|
522
|
+
const CLOSE_OUTCOMES = ['rate_limited', 'deferred', 'rule_tuning', 'new_alert_rule', 'ambiguous', 'skipped', 'other'];
|
|
523
|
+
|
|
524
|
+
// Close an IP row without blocking or marking it a false positive ("not now", "rule needs tuning",
|
|
525
|
+
// "already rate-limited"...). Unlike `report`, the row leaves the queue.
|
|
526
|
+
async function close(args, flags) {
|
|
527
|
+
requireAuth();
|
|
528
|
+
const ref = args[0];
|
|
529
|
+
const { task } = await resolveTask(ref, flags);
|
|
530
|
+
if (!task.ip) {
|
|
531
|
+
ui.error('`close` is for IP rows. For a case-level plan use: securenow human action <row> --status approved|rejected');
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
const outcome = flags.outcome || 'skipped';
|
|
535
|
+
if (!CLOSE_OUTCOMES.includes(outcome)) {
|
|
536
|
+
ui.error(`--outcome must be one of: ${CLOSE_OUTCOMES.join(', ')}`);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
const summary = flags.summary || flags.reason;
|
|
540
|
+
if (!summary) {
|
|
541
|
+
ui.error('Say why with --reason "..." (and --missing-proof "..." when evidence is missing): the reason is kept on the case.');
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!flags.yes && !flags.force) {
|
|
546
|
+
const ok = await ui.confirm(`Close ${task.ip} without blocking it (${outcome})?`);
|
|
547
|
+
if (!ok) return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const s = ui.spinner(`Closing ${task.ip}`);
|
|
551
|
+
try {
|
|
552
|
+
const decisionReport = buildDecisionReport(task, flags, outcome, summary);
|
|
553
|
+
const data = await api.post(
|
|
554
|
+
`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}/close-no-write`,
|
|
555
|
+
{ reportSource: 'cli', reason: flags.reason || summary, summary, outcome, decisionReport }
|
|
556
|
+
);
|
|
557
|
+
s.stop(`${task.ip} closed (${outcome})`);
|
|
558
|
+
if (flags.json) ui.json(data);
|
|
559
|
+
else ui.success(`Closed without enforcement: ${task.ip} is out of the queue, nothing was blocked. The reason is on the case.`);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
s.fail('Failed to close the row');
|
|
562
|
+
throw err;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
519
566
|
function mcpPromptText(ref, flags = {}) {
|
|
520
567
|
const rowText = ref ? `Start with human action row/id: ${ref}.` : 'Work the human action queue from most urgent to least urgent.';
|
|
521
568
|
const limit = flags.limit || 10;
|
|
@@ -569,6 +616,66 @@ async function work(args, flags) {
|
|
|
569
616
|
console.log('');
|
|
570
617
|
}
|
|
571
618
|
|
|
619
|
+
// ── Rule promotion requests ──
|
|
620
|
+
// Agents create rules in test mode and ask a person to promote them. A person approves (the rule
|
|
621
|
+
// goes to prod, recorded as a rule version) or denies here or on the dashboard's rules page.
|
|
622
|
+
|
|
623
|
+
async function promotions(args, flags) {
|
|
624
|
+
requireAuth();
|
|
625
|
+
const status = typeof flags.status === 'string' ? flags.status : 'pending';
|
|
626
|
+
const s = ui.spinner('Fetching promotion requests');
|
|
627
|
+
let data;
|
|
628
|
+
try {
|
|
629
|
+
data = await api.get('/alert-rules/promotion-requests', { query: { status, limit: flags.limit || 50 } });
|
|
630
|
+
s.stop(`Found ${(data.requests || []).length} ${status === 'all' ? '' : `${status} `}request${(data.requests || []).length === 1 ? '' : 's'}`);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
s.fail('Failed to fetch promotion requests');
|
|
633
|
+
throw err;
|
|
634
|
+
}
|
|
635
|
+
if (flags.json) { ui.json(data); return; }
|
|
636
|
+
const requests = data.requests || [];
|
|
637
|
+
if (requests.length === 0) { ui.info('No promotion requests waiting.'); return; }
|
|
638
|
+
console.log('');
|
|
639
|
+
for (const r of requests) {
|
|
640
|
+
console.log(` ${ui.c.bold(r.ruleName || r.ruleId)} ${ui.c.dim(r.id)} ${r.status}`);
|
|
641
|
+
console.log(` asked by ${r.requestedBy?.name || r.requestedBy?.kind || 'unknown'} ${ui.timeAgo(r.createdAt)}: ${r.reason}`);
|
|
642
|
+
if (r.evidence) console.log(ui.c.dim(` evidence: ${ui.truncate(String(r.evidence).replace(/\s+/g, ' '), 160)}`));
|
|
643
|
+
if (r.status !== 'pending') console.log(ui.c.dim(` ${r.status} by ${r.decidedByName || 'someone'}${r.decisionNote ? `: ${r.decisionNote}` : ''}`));
|
|
644
|
+
}
|
|
645
|
+
console.log('');
|
|
646
|
+
if (requests.some((r) => r.status === 'pending')) {
|
|
647
|
+
console.log(ui.c.dim(' Approve: securenow human approve-promotion <id> --yes [--note "..."] Deny: securenow human deny-promotion <id> --reason "..."'));
|
|
648
|
+
console.log(ui.c.dim(' Before approving, check what it would arm: securenow alerts rules show <ruleId> and securenow alerts rules runs <ruleId>'));
|
|
649
|
+
console.log('');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function decidePromotion(args, flags, decision) {
|
|
654
|
+
requireAuth();
|
|
655
|
+
const id = args[0];
|
|
656
|
+
const verb = decision === 'approve' ? 'approve-promotion' : 'deny-promotion';
|
|
657
|
+
if (!id) {
|
|
658
|
+
ui.error(`Usage: securenow human ${verb} <request-id> ${decision === 'approve' ? '--yes [--note "..."]' : '--reason "..."'}`);
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
if (decision === 'approve' && !flags.yes && !flags.force) {
|
|
662
|
+
ui.warn('Approving moves the rule to prod: its instant block, matching automations and the AI pipeline can act on its alerts.');
|
|
663
|
+
const ok = await ui.confirm('Approve this promotion?');
|
|
664
|
+
if (!ok) { ui.info('Cancelled'); return; }
|
|
665
|
+
}
|
|
666
|
+
const note = flags.note || flags.reason || '';
|
|
667
|
+
const s = ui.spinner(decision === 'approve' ? 'Approving' : 'Denying');
|
|
668
|
+
try {
|
|
669
|
+
const data = await api.post(`/alert-rules/promotion-requests/${id}/${decision}`, { note });
|
|
670
|
+
s.stop(decision === 'approve' ? `Approved: "${data.alertRule?.name || data.request?.ruleName}" is in prod` : 'Denied: the rule stays in test mode');
|
|
671
|
+
for (const warning of data.warnings || []) ui.warn(warning);
|
|
672
|
+
if (flags.json) ui.json(data);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
s.fail(`Failed to ${decision} the promotion`);
|
|
675
|
+
throw err;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
572
679
|
module.exports = {
|
|
573
680
|
list,
|
|
574
681
|
show,
|
|
@@ -576,6 +683,10 @@ module.exports = {
|
|
|
576
683
|
block,
|
|
577
684
|
fp,
|
|
578
685
|
report,
|
|
686
|
+
close,
|
|
579
687
|
prompt,
|
|
580
688
|
work,
|
|
689
|
+
promotions,
|
|
690
|
+
approvePromotion: (args, flags) => decidePromotion(args, flags, 'approve'),
|
|
691
|
+
denyPromotion: (args, flags) => decidePromotion(args, flags, 'deny'),
|
|
581
692
|
};
|
package/cli/monitor.js
CHANGED
|
@@ -294,6 +294,48 @@ async function logsList(args, flags) {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// Error lines grouped by message (ids and numbers normalized): what is failing, how often, since when.
|
|
298
|
+
async function logsErrors(args, flags) {
|
|
299
|
+
requireAuth();
|
|
300
|
+
const appKey = resolveApp(flags);
|
|
301
|
+
if (!appKey) {
|
|
302
|
+
ui.error('No app specified. Use --app <key> or set a default.');
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
const query = { appKeys: appKey, since: flags.since || '1h', limit: flags.limit || 20 };
|
|
306
|
+
if (flags.warn) query.includeWarn = 'true';
|
|
307
|
+
addEnvironment(query, flags);
|
|
308
|
+
const s = ui.spinner('Grouping error logs');
|
|
309
|
+
let data;
|
|
310
|
+
try {
|
|
311
|
+
data = await api.get('/logs/errors', { query });
|
|
312
|
+
const kind = data.includeWarn ? 'error/warning' : 'error';
|
|
313
|
+
s.stop(`${data.total} ${kind} line${data.total === 1 ? '' : 's'} in ${data.distinct} group${data.distinct === 1 ? '' : 's'}`);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
s.fail('Failed to summarize error logs');
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
if (flags.json) { ui.json(data); return; }
|
|
319
|
+
const groups = data.groups || [];
|
|
320
|
+
console.log('');
|
|
321
|
+
if (groups.length === 0) { ui.info(`No ${data.includeWarn ? 'warnings or errors' : 'errors'} since ${data.window?.from}.`); return; }
|
|
322
|
+
for (const g of groups) {
|
|
323
|
+
const last = g.lastSeen ? ui.timeAgo(g.lastSeen) : '';
|
|
324
|
+
console.log(` ${ui.c.red(String(g.occurrences).padStart(5))} ${ui.truncate(String(g.sample || g.signature).replace(/\s+/g, ' '), 110)}`);
|
|
325
|
+
const meta = [
|
|
326
|
+
(g.severities || []).join('/'),
|
|
327
|
+
(g.environments || []).join(', '),
|
|
328
|
+
g.exceptionType,
|
|
329
|
+
last && `last ${last}`,
|
|
330
|
+
g.sampleTraceIds?.[0] && `trace ${g.sampleTraceIds[0]}`,
|
|
331
|
+
].filter(Boolean).join(' · ');
|
|
332
|
+
if (meta) console.log(ui.c.dim(` ${meta}`));
|
|
333
|
+
}
|
|
334
|
+
console.log('');
|
|
335
|
+
console.log(ui.c.dim(' A group\'s requests: securenow logs trace <traceId> Ask why: securenow forensics chat --app <key> "<question>"'));
|
|
336
|
+
console.log('');
|
|
337
|
+
}
|
|
338
|
+
|
|
297
339
|
async function logsTrace(args, flags) {
|
|
298
340
|
requireAuth();
|
|
299
341
|
const traceId = args[0];
|
|
@@ -499,6 +541,7 @@ module.exports = {
|
|
|
499
541
|
tracesAnalyze,
|
|
500
542
|
logsList,
|
|
501
543
|
logsTrace,
|
|
544
|
+
logsErrors,
|
|
502
545
|
notificationsList,
|
|
503
546
|
notificationsRead,
|
|
504
547
|
notificationsReadAll,
|
package/cli/review.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Alert Review: "is this rule right?". A rule fired on too many IPs, or on an IP SecureNow treats as
|
|
4
|
+
// your own infrastructure, so it was paused and the run waits for a person. Rows that share one root
|
|
5
|
+
// cause (same rule, route, evidence shape) are grouped so one decision can close them all. Closing a
|
|
6
|
+
// row never blocks or unblocks anything; keeping a rule on is the only write to the rule itself.
|
|
7
|
+
|
|
8
|
+
const { api, requireAuth } = require('./client');
|
|
9
|
+
const ui = require('./ui');
|
|
10
|
+
|
|
11
|
+
const OUTCOMES = ['rule_tuning', 'false_positive', 'clean', 'deferred', 'new_alert_rule', 'rate_limited', 'ambiguous', 'skipped', 'other'];
|
|
12
|
+
|
|
13
|
+
const VERDICTS = {
|
|
14
|
+
false_positive_pattern: 'looks benign',
|
|
15
|
+
rule_tuning: 'rule needs tuning',
|
|
16
|
+
real_probe: 'real probe',
|
|
17
|
+
ambiguous: 'unclear',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function verdictLabel(decisionTypes = {}) {
|
|
21
|
+
const keys = Object.keys(decisionTypes);
|
|
22
|
+
if (keys.length === 0) return '-';
|
|
23
|
+
if (keys.length > 1) return 'mixed';
|
|
24
|
+
return VERDICTS[keys[0]] || keys[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Alerts carry app keys; print the names people know them by.
|
|
28
|
+
async function appNames() {
|
|
29
|
+
try {
|
|
30
|
+
const data = await api.get('/applications');
|
|
31
|
+
return new Map((data.applications || []).map((app) => [app.key, app.name]));
|
|
32
|
+
} catch {
|
|
33
|
+
return new Map();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function namesOf(keys, names) {
|
|
38
|
+
return (keys || []).map((key) => names.get(key) || key).join(', ') || '-';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isNotificationId(ref) {
|
|
42
|
+
return /^[0-9a-f]{24}$/i.test(String(ref || ''));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fetchGroups(flags = {}) {
|
|
46
|
+
const query = { page: flags.page || 1, limit: flags.limit || 20 };
|
|
47
|
+
if (flags.search) query.search = flags.search;
|
|
48
|
+
return api.get('/notifications/alert-review-runs', { query });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A group is named by its row on the current page or by its root-cause key (arc_...).
|
|
52
|
+
async function resolveGroup(ref, flags = {}) {
|
|
53
|
+
if (!ref) {
|
|
54
|
+
ui.error('Group row or root-cause key required. Use `securenow review list` first.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const text = String(ref).trim();
|
|
58
|
+
if (/^\d+$/.test(text)) {
|
|
59
|
+
// Rows are numbered across pages, as `review list` prints them.
|
|
60
|
+
const row = Math.max(1, parseInt(text, 10));
|
|
61
|
+
const limit = Number(flags.limit) || 20;
|
|
62
|
+
const data = await fetchGroups({ ...flags, limit, page: Math.ceil(row / limit) });
|
|
63
|
+
const group = (data.groups || [])[(row - 1) % limit];
|
|
64
|
+
if (!group) {
|
|
65
|
+
ui.error(`Row ${text} was not found. Run \`securenow review list\` again: the queue may have changed.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
return group;
|
|
69
|
+
}
|
|
70
|
+
const data = await api.get('/notifications/alert-review-runs', { query: { rootCauseKey: text, limit: 1 } });
|
|
71
|
+
const group = (data.groups || [])[0];
|
|
72
|
+
if (!group) {
|
|
73
|
+
ui.error(`No open group "${text}". It may already be closed.`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
return group;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireReason(flags) {
|
|
80
|
+
const reason = flags.reason || flags.summary;
|
|
81
|
+
if (!reason) {
|
|
82
|
+
ui.error('Say why with --reason "...": the reason is kept on the alert as its decision report.');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
return reason;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireOutcome(flags, fallback) {
|
|
89
|
+
const outcome = flags.outcome || fallback;
|
|
90
|
+
if (!OUTCOMES.includes(outcome)) {
|
|
91
|
+
ui.error(`--outcome must be one of: ${OUTCOMES.join(', ')}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
return outcome;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function list(args, flags) {
|
|
98
|
+
requireAuth();
|
|
99
|
+
const s = ui.spinner('Fetching Alert Review');
|
|
100
|
+
let data;
|
|
101
|
+
try {
|
|
102
|
+
data = await fetchGroups(flags);
|
|
103
|
+
const summary = data.summary || {};
|
|
104
|
+
s.stop(`${summary.totalRuns ?? 0} alert run${summary.totalRuns === 1 ? '' : 's'} to review in ${summary.totalGroups ?? 0} group${summary.totalGroups === 1 ? '' : 's'}`);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
s.fail('Failed to fetch Alert Review');
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
if (flags.json) { ui.json(data); return; }
|
|
110
|
+
|
|
111
|
+
const groups = data.groups || [];
|
|
112
|
+
console.log('');
|
|
113
|
+
if (!groups.length) {
|
|
114
|
+
ui.info('Nothing to review: no paused or noisy rule is waiting for a decision.');
|
|
115
|
+
console.log('');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const offset = ((data.pagination?.page || 1) - 1) * (data.pagination?.limit || groups.length);
|
|
119
|
+
const rows = groups.map((group, index) => [
|
|
120
|
+
String(offset + index + 1),
|
|
121
|
+
String(group.count || 0),
|
|
122
|
+
ui.truncate(group.rootCause?.detectionName || group.title || '-', 40),
|
|
123
|
+
ui.truncate(group.rootCause?.routeTemplate || '-', 28),
|
|
124
|
+
verdictLabel(group.decisionTypes),
|
|
125
|
+
group.batchActionPreview?.canApplyOneDecision ? 'yes' : 'no',
|
|
126
|
+
group.lastOccurredAt ? ui.timeAgo(group.lastOccurredAt) : '-',
|
|
127
|
+
]);
|
|
128
|
+
ui.table(['Row', 'Runs', 'Rule', 'Route', 'Looks like', 'One decision', 'Last'], rows, { maxColWidth: 44 });
|
|
129
|
+
console.log('');
|
|
130
|
+
ui.info('Inspect a group: securenow review show <row>');
|
|
131
|
+
ui.info('Rule is right, keep it on: securenow review keep <notificationId> --reason "..."');
|
|
132
|
+
ui.info('Close a whole group: securenow review group <row> --outcome rule_tuning --reason "..." [--preview]');
|
|
133
|
+
console.log('');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function printRun(run, names) {
|
|
137
|
+
ui.keyValue([
|
|
138
|
+
['Alert', `${run.title || '-'} ${ui.c.dim(run.notificationId)}`],
|
|
139
|
+
['Rule', `${run.alertRule?.name || run.rootCause?.alertRuleName || '-'}${run.alertRule?.status ? ` (${run.alertRule.status})` : ''}`],
|
|
140
|
+
['Route', run.rootCause?.routeTemplate || '-'],
|
|
141
|
+
['Looks like', VERDICTS[run.rootCause?.decisionType] || run.rootCause?.decisionType || '-'],
|
|
142
|
+
['Who sent it', run.rootCause?.attributionType || '-'],
|
|
143
|
+
['Apps', namesOf(run.applications, names)],
|
|
144
|
+
['IP', run.primaryIp || '-'],
|
|
145
|
+
['Evidence', `${run.evidenceQuality?.score ?? '-'} / 100${run.proofContract?.missingProof?.length ? `, missing: ${run.proofContract.missingProof.join(', ')}` : ''}`],
|
|
146
|
+
['Proposed guard', run.rootCause?.proposedGuard || '-'],
|
|
147
|
+
['Last seen', run.lastOccurredAt ? ui.timeAgo(run.lastOccurredAt) : '-'],
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function show(args, flags) {
|
|
152
|
+
requireAuth();
|
|
153
|
+
const ref = args[0];
|
|
154
|
+
if (isNotificationId(ref)) {
|
|
155
|
+
const data = await api.get(`/notifications/alert-review-runs/${ref}`);
|
|
156
|
+
if (flags.json) { ui.json(data); return; }
|
|
157
|
+
ui.heading('Alert run');
|
|
158
|
+
printRun(data.run, await appNames());
|
|
159
|
+
if (data.group) {
|
|
160
|
+
console.log('');
|
|
161
|
+
ui.info(`${data.group.count} open run${data.group.count === 1 ? '' : 's'} share this root cause (${data.group.key}).`);
|
|
162
|
+
}
|
|
163
|
+
console.log('');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const group = await resolveGroup(ref, flags);
|
|
168
|
+
if (flags.json) { ui.json(group); return; }
|
|
169
|
+
const names = await appNames();
|
|
170
|
+
ui.heading(group.title || 'Alert Review group');
|
|
171
|
+
ui.keyValue([
|
|
172
|
+
['Key', group.key],
|
|
173
|
+
['Runs', String(group.count)],
|
|
174
|
+
['Looks like', verdictLabel(group.decisionTypes)],
|
|
175
|
+
['Apps', namesOf(group.applications, names)],
|
|
176
|
+
['IPs', `${(group.ips || []).length}${group.ips?.length ? `: ${group.ips.slice(0, 5).join(', ')}${group.ips.length > 5 ? ' ...' : ''}` : ''}`],
|
|
177
|
+
['Evidence', `${group.evidenceQuality?.min ?? '-'}-${group.evidenceQuality?.max ?? '-'} / 100`],
|
|
178
|
+
['Proposed guard', group.rootCause?.proposedGuard || '-'],
|
|
179
|
+
['One decision', group.batchActionPreview?.canApplyOneDecision ? 'yes' : `no: ${group.batchActionPreview?.reason || 'single run'}`],
|
|
180
|
+
]);
|
|
181
|
+
if (group.runs?.length) {
|
|
182
|
+
console.log('');
|
|
183
|
+
ui.table(['Alert id', 'IP', 'Evidence', 'Last'], group.runs.map((run) => [
|
|
184
|
+
run.notificationId,
|
|
185
|
+
run.primaryIp || '-',
|
|
186
|
+
String(run.evidenceQuality?.score ?? '-'),
|
|
187
|
+
run.lastOccurredAt ? ui.timeAgo(run.lastOccurredAt) : '-',
|
|
188
|
+
]));
|
|
189
|
+
}
|
|
190
|
+
console.log('');
|
|
191
|
+
ui.info(`One run: securenow review show <alert id>`);
|
|
192
|
+
ui.info(`All runs: securenow review group ${group.key} --outcome rule_tuning --reason "..." --preview`);
|
|
193
|
+
console.log('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function keep(args, flags) {
|
|
197
|
+
requireAuth();
|
|
198
|
+
const id = args[0];
|
|
199
|
+
if (!isNotificationId(id)) {
|
|
200
|
+
ui.error('Usage: securenow review keep <notificationId> --reason "..." --yes');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
const reason = requireReason(flags);
|
|
204
|
+
if (!flags.yes && !flags.force) {
|
|
205
|
+
ui.warn('This turns the rule back on and stops it from pausing itself again.');
|
|
206
|
+
const ok = await ui.confirm('Keep the rule on and close this run?');
|
|
207
|
+
if (!ok) return;
|
|
208
|
+
}
|
|
209
|
+
const s = ui.spinner('Keeping the rule on');
|
|
210
|
+
try {
|
|
211
|
+
const data = await api.post(`/notifications/alert-review-runs/${id}/keep-alive`, { confirm: true, reason, reportSource: 'cli' });
|
|
212
|
+
s.stop(`"${data.alertRule?.name || 'Rule'}" is ${data.verification?.ruleActive ? 'on' : data.alertRule?.status || 'unchanged'}; the run is closed`);
|
|
213
|
+
if (flags.json) ui.json(data);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
s.fail('Failed to keep the rule on');
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function closeOne(args, flags, mode) {
|
|
221
|
+
requireAuth();
|
|
222
|
+
const id = args[0];
|
|
223
|
+
if (!isNotificationId(id)) {
|
|
224
|
+
ui.error(`Usage: securenow review ${mode} <notificationId> ${mode === 'close' ? '--outcome rule_tuning ' : ''}--reason "..." --yes`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
const outcome = requireOutcome(flags, 'skipped');
|
|
228
|
+
const reason = requireReason(flags);
|
|
229
|
+
if (!flags.yes && !flags.force) {
|
|
230
|
+
const ok = await ui.confirm(`${mode === 'close' ? 'Close' : 'Dismiss'} this run (${outcome})? Nothing is blocked or unblocked.`);
|
|
231
|
+
if (!ok) return;
|
|
232
|
+
}
|
|
233
|
+
const route = mode === 'close' ? 'resolve' : 'dismiss';
|
|
234
|
+
const s = ui.spinner(mode === 'close' ? 'Closing the run' : 'Dismissing the run');
|
|
235
|
+
try {
|
|
236
|
+
const data = await api.post(`/notifications/alert-review-runs/${id}/${route}`, {
|
|
237
|
+
confirm: true,
|
|
238
|
+
outcome,
|
|
239
|
+
reason,
|
|
240
|
+
summary: flags.summary || reason,
|
|
241
|
+
reportSource: 'cli',
|
|
242
|
+
});
|
|
243
|
+
s.stop(data.alreadyClosed ? 'Already closed' : `Run ${mode === 'close' ? 'closed' : 'dismissed'} (${outcome}); the reason is on the alert`);
|
|
244
|
+
if (flags.json) ui.json(data);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
s.fail(`Failed to ${mode} the run`);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function group(args, flags) {
|
|
252
|
+
requireAuth();
|
|
253
|
+
const target = await resolveGroup(args[0], flags);
|
|
254
|
+
const preview = Boolean(flags.preview);
|
|
255
|
+
const outcome = preview ? flags.outcome : requireOutcome(flags);
|
|
256
|
+
const reason = preview ? flags.reason : requireReason(flags);
|
|
257
|
+
if (!preview && !flags.yes && !flags.force) {
|
|
258
|
+
ui.warn(`This closes ${target.count} run${target.count === 1 ? '' : 's'} of "${target.title}". Nothing is blocked or unblocked.`);
|
|
259
|
+
const ok = await ui.confirm(`Close the group (${outcome})?`);
|
|
260
|
+
if (!ok) return;
|
|
261
|
+
}
|
|
262
|
+
const s = ui.spinner(preview ? 'Previewing the group decision' : 'Closing the group');
|
|
263
|
+
try {
|
|
264
|
+
const data = await api.post('/notifications/alert-review-runs/apply-group-decision', {
|
|
265
|
+
rootCauseKey: target.key,
|
|
266
|
+
previewOnly: preview,
|
|
267
|
+
confirm: !preview,
|
|
268
|
+
allowMixed: Boolean(flags['allow-mixed']),
|
|
269
|
+
outcome,
|
|
270
|
+
reason,
|
|
271
|
+
summary: flags.summary || reason,
|
|
272
|
+
reportSource: 'cli',
|
|
273
|
+
});
|
|
274
|
+
if (preview) {
|
|
275
|
+
s.stop(`Would close ${data.willApplyCount} run${data.willApplyCount === 1 ? '' : 's'}`);
|
|
276
|
+
if (target.mixed) ui.warn('Mixed group: rows disagree on verdict or sender. Review each row, then pass --allow-mixed.');
|
|
277
|
+
} else {
|
|
278
|
+
s.stop(`Closed ${data.closedCount} of ${data.requestedCount}${data.failedCount ? `, ${data.failedCount} failed` : ''}; ${data.verification?.remainingOpenInGroup ?? 0} still open in the group`);
|
|
279
|
+
}
|
|
280
|
+
if (flags.json) ui.json(data);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
s.fail(preview ? 'Preview failed' : 'Failed to close the group');
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
list,
|
|
289
|
+
show,
|
|
290
|
+
keep,
|
|
291
|
+
close: (args, flags) => closeOne(args, flags, 'close'),
|
|
292
|
+
dismiss: (args, flags) => closeOne(args, flags, 'dismiss'),
|
|
293
|
+
group,
|
|
294
|
+
OUTCOMES,
|
|
295
|
+
};
|