securenow 8.21.0 → 8.23.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/SKILL-CLI.md CHANGED
@@ -208,10 +208,28 @@ Auto-detects framework (Next.js, Nuxt, Express, Fastify, Koa, Hapi, Node) from `
208
208
  ### Dashboard & Status
209
209
 
210
210
  ```bash
211
+ securenow today [--since 24h|7d] [--attackers 20] [--json] # the Overview: what happened, what SecureNow did, what needs you
212
+ securenow attacker <ip> [--json] # why an address was flagged: verdict, signals, traffic vs normal, detections, timeline
211
213
  securenow status [--app <key>] [--env local|production|all] # dashboard overview
212
214
  securenow analytics [--app <key>] # response analytics
213
215
  ```
214
216
 
217
+ `today` is the dashboard's home in the terminal:
218
+ - whether each app is covered (data flowing, firewall on, detection monitoring or blocking);
219
+ - attack requests, attackers, what the firewall stopped and what got through;
220
+ - the decisions waiting for you, the top attackers, and what SecureNow did (the agent's actions, automatic blocks,
221
+ closed probes).
222
+
223
+ `attacker <ip>` explains one address:
224
+ - the verdict and score with the signals behind them, and its reputation;
225
+ - where it is blocked;
226
+ - its last 3 days of traffic against your apps' normal (error rate, share of traffic, scanner probes, attack
227
+ payloads answered 2xx);
228
+ - the detections it triggered and what happened to each, a timeline, and questions to paste into
229
+ `securenow forensics chat`.
230
+
231
+ MCP: `securenow_overview` and `securenow_attacker` (read).
232
+
215
233
  ---
216
234
 
217
235
  ### Traces
@@ -398,6 +416,7 @@ securenow alerts rules change <id> "raise the threshold to 20" # rewrite a rul
398
416
  securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
399
417
  securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
400
418
  securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
419
+ securenow alerts rules quality <id> # was the rule right to fire? 14-day precision, alerts labeled wrong/unclear and why, what detection learning changed
401
420
  securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait # candidate test against a specific host rule
402
421
  securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
403
422
  securenow alerts rules exclusions <id> list # embedded rule exclusions
@@ -487,10 +506,14 @@ securenow protect enable --app <key> # turn it on (monitor mode)
487
506
  securenow protect block --app <key> # a person turns blocking on; refused unless the app sees real client IPs
488
507
  securenow protect monitor --app <key> # back to alerts only
489
508
  securenow protect disable --app <key> # off; stays off until someone turns it on
509
+ securenow protect learn --app <key> # detection learning now (it also runs hourly): label alerts, measure precision, learn safe exclusions
510
+ securenow protect status --rules # adds each rule's 14-day precision (right / right + wrong)
490
511
  ```
491
512
 
492
513
  Only a person signed in (`securenow admin login`) can turn blocking on; API keys, MCP connectors and the agent get a 403. Blocking is refused when more than 20% of the app's traffic reaches it through a proxy the SDK doesn't trust: add the proxy to `config.networking.trustedProxies` (IPs, IPv4 ranges or `"cloudflare"`). Tune a noisy starter rule like any rule (exclusions, `alerts rules update`); pack updates never overwrite a rule a person edited.
493
514
 
515
+ **Detection learning** keeps the starter rules precise per app. Every alert gets a label: was the rule right to fire? A blocked address counts as right, a person's false-positive call as wrong, and an LLM judge (accounts with AI access) labels the rest from the requests and the address's last day of traffic; a failed one-off probe (`/.env` answered 404) is closed as right but not worth action. Precision = right / (right + wrong) over 14 days, target 95%. A rule below it with 3+ wrong alerts gets one exclusion for the legitimate traffic, applied only when it matches the wrong alerts and none of the right ones, can't be pinned to an IP, and still lets every reference attack through even when injected into the excused request; otherwise the rule is flagged for a person. A block-ready rule under 95% (10+ labeled alerts) keeps the app from being ready to block. MCP: `securenow_alert_rule_quality`, `securenow_protection_learn`.
516
+
494
517
  ### Blocklist — Block Malicious IPs
495
518
 
496
519
  ```bash
package/cli/protect.js CHANGED
@@ -31,13 +31,27 @@ function modeLabel(app) {
31
31
  return ui.c.yellow(app.state === 'unset' ? 'NOT YET ON' : 'OFF');
32
32
  }
33
33
 
34
+ // Precision: of the alerts labeled right or wrong (detection learning), the share where the rule was right.
35
+ const PRECISION_TARGET = 0.95;
36
+
37
+ function precisionCell(quality) {
38
+ if (!quality || quality.precision == null) return ui.c.dim('-');
39
+ const decided = quality.decided ?? (quality.correct || 0) + (quality.wrong || 0);
40
+ const text = `${(quality.precision * 100).toFixed(quality.precision === 1 ? 0 : 1)}% (${quality.correct}/${decided})`;
41
+ return quality.precision >= PRECISION_TARGET ? ui.c.green(text) : ui.c.yellow(text);
42
+ }
43
+
34
44
  function printApp(app, { verbose }) {
35
45
  console.log('');
36
46
  console.log(` ${ui.c.bold(app.appName)} ${ui.c.dim(app.appKey)} ${modeLabel(app)}`);
37
47
  const summary = app.summary || {};
48
+ const quality = summary.quality || {};
38
49
  ui.keyValue([
39
50
  ['Rules', `${summary.active || 0} active of ${summary.rules || 0} (${summary.blockReady || 0} can block, ${summary.blocking || 0} blocking)`],
40
51
  ['Alerts, last 7 days', `${summary.alerts7d || 0} (${summary.falsePositives7d || 0} marked false positive)`],
52
+ ['Precision, last 14 days', quality.precision == null
53
+ ? ui.c.dim('not measured yet: no labeled alerts')
54
+ : `${precisionCell(quality)} right${quality.wrong ? `, ${quality.wrong} wrong` : ''}${quality.unclear ? `, ${quality.unclear} unclear` : ''}`],
41
55
  ['Pack version', `${app.packVersion || '-'} (latest ${app.latestPackVersion})${summary.customized ? `, ${summary.customized} rule(s) edited by a person` : ''}${summary.missing ? `, ${summary.missing} missing` : ''}`],
42
56
  ]);
43
57
  if (app.mode === 'monitor') {
@@ -50,16 +64,18 @@ function printApp(app, { verbose }) {
50
64
  }
51
65
  if (verbose) {
52
66
  ui.table(
53
- ['Rule', 'Kind', 'Mode', 'Status', 'Alerts 7d', 'Blocks'],
67
+ ['Rule', 'Kind', 'Mode', 'Status', 'Alerts 7d', 'Precision 14d', 'Blocks'],
54
68
  (app.rules || []).map((rule) => [
55
69
  ui.truncate(rule.name, 52),
56
70
  rule.kind,
57
71
  rule.present ? rule.mode : '-',
58
72
  rule.present ? `${rule.status}${rule.customized ? ' (edited)' : ''}` : ui.c.dim('missing'),
59
73
  String(rule.alerts7d || 0),
74
+ rule.present ? precisionCell(rule.quality) : '-',
60
75
  rule.blocking ? ui.c.green('yes') : rule.blockReady ? ui.c.dim('ready') : ui.c.dim('alerts only'),
61
76
  ])
62
77
  );
78
+ console.log(` ${ui.c.dim('A rule\'s labeled alerts and what detection learning changed: securenow alerts rules quality <rule-id> (ids: --json)')}`);
63
79
  }
64
80
  }
65
81
 
@@ -134,4 +150,50 @@ async function monitor(args, flags) {
134
150
  return change('/protection/monitor', flags, { verb: 'Switching to monitor mode for' });
135
151
  }
136
152
 
137
- module.exports = { status, enable, disable, block, monitor };
153
+ const LEARNED = {
154
+ exclusion_added: (item) => `${item.ruleKey} on ${item.appKey}: learned an exclusion for legitimate traffic; precision on the same alerts ${item.after && item.after.precision != null ? `${(item.after.precision * 100).toFixed(1)}%` : 'recomputed next run'}`,
155
+ needs_person: (item) => `${item.ruleKey} on ${item.appKey}: below ${PRECISION_TARGET * 100}% and no exclusion passed the safety checks; a person should tune it${item.refusals && item.refusals[0] ? ` (${ui.truncate(item.refusals[0], 120)})` : ''}`,
156
+ };
157
+
158
+ // Detection learning now (it also runs every hour): label the starter rules' alerts (was the rule right?), measure
159
+ // each rule's precision, and learn exclusions for legitimate traffic that pass the safety checks.
160
+ async function learn(args, flags) {
161
+ requireAuth();
162
+ const appKey = flags.app ? String(flags.app) : (flags.all ? null : resolveAppKey(flags));
163
+ const s = ui.spinner(appKey ? `Running detection learning for ${appKey}` : 'Running detection learning');
164
+ let data;
165
+ try {
166
+ data = await api.post('/protection/learn', appKey ? { app: appKey } : {});
167
+ s.stop(data.message || 'Done');
168
+ } catch (err) {
169
+ s.fail('Detection learning failed');
170
+ throw err;
171
+ }
172
+ if (flags.json) { ui.json(data); return; }
173
+ const rules = (data.quality || []).filter((rule) => rule.labeled || rule.unclear || rule.unlabeled || rule.superseded);
174
+ console.log('');
175
+ if (rules.length === 0) {
176
+ ui.info('No starter-rule alerts in the last 14 days, so there is nothing to measure yet.');
177
+ } else {
178
+ ui.table(
179
+ ['Rule', 'App', 'Precision', 'Right', 'Wrong', 'Unclear', 'Not labeled', 'Fixed'],
180
+ rules.map((rule) => [
181
+ rule.ruleKey,
182
+ ui.truncate(rule.appKey || '-', 12),
183
+ precisionCell(rule),
184
+ String(rule.correct || 0),
185
+ String(rule.wrong || 0),
186
+ String(rule.unclear || 0),
187
+ String(rule.unlabeled || 0),
188
+ String(rule.fixedWrong || 0),
189
+ ])
190
+ );
191
+ console.log(` ${ui.c.dim('Precision counts the alerts the rule as it is now would still raise; Fixed = wrong alerts a rule change or exclusion no longer raises.')}`);
192
+ }
193
+ for (const item of data.learned || []) {
194
+ console.log(` ${item.kind === 'exclusion_added' ? ui.c.green('learned') : ui.c.yellow('needs you')} ${(LEARNED[item.kind] || ((x) => `${x.ruleKey}: ${x.kind}`))(item)}`);
195
+ }
196
+ console.log('');
197
+ }
198
+
199
+ module.exports = { status, enable, disable, block, monitor, learn };
package/cli/security.js CHANGED
@@ -106,6 +106,9 @@ async function alertRulesRoute(args, flags) {
106
106
  if (sub === 'why' || sub === 'explain') {
107
107
  return alertRuleWhy(args.slice(1), flags);
108
108
  }
109
+ if (sub === 'quality' || sub === 'precision') {
110
+ return alertRuleQuality(args.slice(1), flags);
111
+ }
109
112
  if (sub === 'promote') {
110
113
  return alertRuleSetMode(args.slice(1), flags, 'prod');
111
114
  }
@@ -1322,6 +1325,75 @@ async function alertRuleWhy(args, flags) {
1322
1325
  console.log('');
1323
1326
  }
1324
1327
 
1328
+ // Was the rule right to fire? Its precision over the last 14 days from the labeled alerts, the ones labeled wrong
1329
+ // or unclear and why, and what detection learning changed.
1330
+ async function alertRuleQuality(args, flags) {
1331
+ requireAuth();
1332
+ const id = args[0];
1333
+ if (!id) {
1334
+ ui.error('Usage: securenow alerts rules quality <rule-id> [--json]');
1335
+ process.exit(1);
1336
+ }
1337
+ const s = ui.spinner('Reading the rule\'s labeled alerts');
1338
+ let data;
1339
+ try {
1340
+ data = await api.get(`/alert-rules/${id}/quality`);
1341
+ s.stop('');
1342
+ } catch (err) {
1343
+ s.fail('Failed to read the rule\'s quality');
1344
+ throw err;
1345
+ }
1346
+ if (flags.json) { ui.json(data); return; }
1347
+ const q = data.quality || {};
1348
+ const target = data.target || 0.95;
1349
+ const pct = (value) => `${(value * 100).toFixed(value === 1 ? 0 : 1)}%`;
1350
+ console.log('');
1351
+ ui.heading(data.rule?.name || 'Rule quality');
1352
+ ui.keyValue([
1353
+ [`Precision, last ${data.windowDays || 14} days`, q.precision == null
1354
+ ? ui.c.dim('not measured yet: no alert labeled right or wrong')
1355
+ : (q.precision >= target ? ui.c.green : ui.c.yellow)(`${pct(q.precision)} (target ${pct(target)})`)],
1356
+ ['Right', `${q.correct || 0} (${q.actionable || 0} worth acting on)`],
1357
+ ['Wrong', String(q.wrong || 0)],
1358
+ ['Unclear', String(q.unclear || 0)],
1359
+ ['Not labeled yet', String(q.unlabeled || 0)],
1360
+ ['No longer raised', `${q.superseded || 0}: ${q.fixedWrong || 0} wrong alert(s) fixed by a rule change or exclusion, ${q.droppedCorrect || 0} correct one(s) dropped`],
1361
+ ['Never reached anyone', `${q.dismissed || 0} (closed by an exclusion or the pre-filter)`],
1362
+ ]);
1363
+ const rowsTable = (title, rows) => {
1364
+ if (!rows || rows.length === 0) return;
1365
+ console.log('');
1366
+ console.log(` ${ui.c.bold(title)}`);
1367
+ ui.table(['When (UTC)', 'Address', 'Request', 'Answer', 'By', 'Why'], rows.map((row) => [
1368
+ new Date(row.at).toISOString().replace('T', ' ').slice(0, 16),
1369
+ row.ip,
1370
+ ui.truncate((row.paths || [])[0] || '-', 40),
1371
+ (row.answers || []).join(',') || '-',
1372
+ row.labeledBy || '-',
1373
+ ui.truncate(row.benignPattern || row.reason || '', 60),
1374
+ ]));
1375
+ };
1376
+ rowsTable('Labeled wrong', data.wrong);
1377
+ rowsTable('Unclear', data.unclear);
1378
+ rowsTable('Wrong alerts the rule no longer raises (fixed)', data.fixed);
1379
+ rowsTable('Correct alerts the rule no longer raises (check the change didn\'t lose real attacks)', data.dropped);
1380
+ if ((data.learning || []).length) {
1381
+ console.log('');
1382
+ console.log(` ${ui.c.bold('Detection learning')}`);
1383
+ for (const action of data.learning) {
1384
+ const when = new Date(action.at).toISOString().slice(0, 10);
1385
+ if (action.kind === 'exclusion_added') {
1386
+ console.log(` ${when} ${ui.c.green('exclusion learned')} ${pct(action.before?.precision ?? 0)} → ${pct(action.after?.precision ?? 0)} on the same alerts${action.exclusionId ? ui.c.dim(` (exclusion ${action.exclusionId})`) : ''}`);
1387
+ } else {
1388
+ console.log(` ${when} ${ui.c.yellow('needs a person')} ${ui.truncate(action.reason || '', 110)}`);
1389
+ }
1390
+ }
1391
+ }
1392
+ console.log('');
1393
+ console.log(ui.c.dim(` Exclusions on this rule: securenow alerts rules exclusions ${id} list`));
1394
+ console.log('');
1395
+ }
1396
+
1325
1397
  // An agent hands the promotion of its test-mode rule to a person: the request waits on the rules
1326
1398
  // page and in `securenow human promotions` until someone approves or denies it.
1327
1399
  async function alertRuleRequestPromotion(args, flags) {
package/cli/today.js ADDED
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // The dashboard's one view in the terminal (GET /overview): is every app covered, what happened, what needs you,
4
+ // the top attackers and what SecureNow did. `securenow attacker <ip>` explains one address
5
+ // (GET /overview/attackers/:ip). The same words as the dashboard.
6
+ const { api, requireAuth } = require('./client');
7
+ const ui = require('./ui');
8
+
9
+ const OUTCOMES = {
10
+ stopped: 'Stopped',
11
+ not_a_threat: 'Not a threat',
12
+ no_action: 'No action needed',
13
+ needs_you: 'Needs you',
14
+ being_checked: 'Being checked',
15
+ watching: 'Watching',
16
+ };
17
+ const LEVELS = { malicious: 'Malicious', blocked: 'Known attacker', suspicious: 'Suspicious', low: 'Low risk', unknown: 'Not scored', benign: 'Looks benign', insufficient_data: 'Not enough data', never_block: 'Never blocked' };
18
+ const MODES = { blocking: 'Blocking', monitoring: 'Monitoring', off: 'Off' };
19
+
20
+ function count(n) {
21
+ if (n === null || n === undefined) return '—';
22
+ return Number(n).toLocaleString('en-US');
23
+ }
24
+
25
+ function when(value) {
26
+ if (!value) return '';
27
+ const date = new Date(value);
28
+ const sameDay = date.toDateString() === new Date().toDateString();
29
+ return sameDay
30
+ ? date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
31
+ : date.toLocaleString('en-GB', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
32
+ }
33
+
34
+ function level(item) {
35
+ const label = LEVELS[item.level] || item.level || '';
36
+ const text = item.score !== null && item.score !== undefined ? `${label} ${Math.round(item.score)}` : label;
37
+ return item.level === 'malicious' || item.level === 'blocked' ? ui.c.red(text) : item.level === 'suspicious' ? ui.c.yellow(text) : ui.c.dim(text);
38
+ }
39
+
40
+ function hoursFrom(flags) {
41
+ const since = String(flags.since || '24h');
42
+ const match = since.match(/^(\d+)\s*([hd])$/i);
43
+ if (!match) return 24;
44
+ const hours = Number(match[1]) * (match[2].toLowerCase() === 'd' ? 24 : 1);
45
+ return Math.min(168, Math.max(1, hours));
46
+ }
47
+
48
+ async function today(args, flags) {
49
+ requireAuth();
50
+ const hours = hoursFrom(flags);
51
+ const s = ui.spinner('Reading what happened');
52
+ let data;
53
+ try {
54
+ data = await api.get('/overview', { query: { hours, attackers: flags.attackers || 8 } });
55
+ s.stop('SecureNow');
56
+ } catch (err) {
57
+ s.fail('Could not read the overview');
58
+ throw err;
59
+ }
60
+ if (flags.json) { ui.json(data); return; }
61
+
62
+ const totals = data.totals || {};
63
+ console.log('');
64
+ console.log(` ${ui.c.bold(data.headline || '')} ${ui.c.dim(data.agent?.label || '')}`);
65
+ console.log(` ${ui.c.dim(`Last ${hours >= 48 ? `${Math.round(hours / 24)} days` : `${hours} hours`}:`)} ${count(totals.attackRequests)} attack requests · ${count(totals.attackers)} attackers · ${ui.c.green(`${count(totals.stopped)} stopped`)} · got through: ${totals.gotThrough === null || totals.gotThrough === undefined ? '—' : count(totals.gotThrough)}`);
66
+
67
+ console.log('');
68
+ const cards = data.needsYou || [];
69
+ console.log(` ${ui.c.bold(`Needs you (${data.needsYouTotal || 0})`)}`);
70
+ if (cards.length === 0) console.log(` ${ui.c.green('Nothing needs you.')} ${ui.c.dim(`SecureNow handled ${count(totals.handled)} things.`)}`);
71
+ for (const card of cards) {
72
+ console.log(` ${ui.c.yellow('!')} ${card.title}`);
73
+ if (card.detail) console.log(` ${ui.c.dim(card.detail)}`);
74
+ }
75
+
76
+ const attackers = data.attackers || [];
77
+ if (attackers.length) {
78
+ console.log('');
79
+ console.log(` ${ui.c.bold('Top attackers')}`);
80
+ for (const item of attackers) {
81
+ const status = item.blockedUntil ? (item.blockedUntil === 'permanent' ? 'Blocked' : `Blocked until ${when(item.blockedUntil)}`) : OUTCOMES[item.outcome] || '';
82
+ console.log(` ${item.ip.padEnd(16)} ${level(item)} ${ui.c.dim([item.what, (item.apps || []).map((app) => app.name).join(', '), `${count(item.requests)} req`].filter(Boolean).join(' · '))} ${status}`);
83
+ }
84
+ console.log(` ${ui.c.dim('Why an address was flagged: securenow attacker <ip>')}`);
85
+ }
86
+
87
+ const activity = (data.activity || []).slice(0, 8);
88
+ if (activity.length) {
89
+ console.log('');
90
+ console.log(` ${ui.c.bold('What SecureNow did')}`);
91
+ for (const item of activity) {
92
+ console.log(` ${ui.c.dim(when(item.at).padEnd(13))} ${item.text}${item.by === 'agent' ? ui.c.dim(' (agent)') : ''}`);
93
+ if (item.note) console.log(` ${' '.repeat(13)} ${ui.c.yellow(item.note)}`);
94
+ }
95
+ }
96
+
97
+ const apps = (data.apps || []).filter((app) => app.state === 'protected' || app.state === 'attention');
98
+ if (apps.length) {
99
+ console.log('');
100
+ console.log(` ${ui.c.bold('Apps')}`);
101
+ for (const app of apps) {
102
+ const dot = app.state === 'attention' ? ui.c.yellow('●') : ui.c.green('●');
103
+ const issues = (app.issues || []).map((issue) => issue.text).join('; ');
104
+ console.log(` ${dot} ${String(app.name).padEnd(30)} ${(MODES[app.mode] || app.mode).padEnd(11)} ${issues ? ui.c.yellow(issues) : ui.c.dim(`${count(app.stopped)} stopped`)}`);
105
+ }
106
+ }
107
+ console.log('');
108
+ }
109
+
110
+ async function attacker(args, flags) {
111
+ requireAuth();
112
+ const ip = String(args[0] || flags.ip || '').trim();
113
+ if (!ip) {
114
+ ui.error('Usage: securenow attacker <ip> [--json]');
115
+ process.exit(1);
116
+ }
117
+ const s = ui.spinner(`Reading ${ip}`);
118
+ let data;
119
+ try {
120
+ data = await api.get(`/overview/attackers/${encodeURIComponent(ip)}`);
121
+ s.stop(ip);
122
+ } catch (err) {
123
+ s.fail('Could not read this address');
124
+ throw err;
125
+ }
126
+ if (flags.json) { ui.json(data); return; }
127
+
128
+ const { verdict, reputation, state, summary } = data;
129
+ console.log('');
130
+ console.log(` ${ui.c.bold(ip)} ${level({ level: verdict.level, score: verdict.score })} ${ui.c.dim([reputation?.country, reputation?.isp, reputation?.usageType].filter(Boolean).join(' · '))}`);
131
+ const blocked = state.blocked
132
+ ? ui.c.green(`Blocked ${state.blocks.map((block) => `on ${block.app?.name || 'every app'}${block.until ? ` until ${when(block.until)}` : ''}`).join(', ')}`)
133
+ : state.neverBlock ? 'Never blocked (trusted)' : 'Not blocked';
134
+ console.log(` ${blocked}`);
135
+ console.log(` ${ui.c.dim(`First seen ${when(summary.firstSeen) || '—'} · last seen ${when(summary.lastSeen) || '—'} · ${count(summary.alerts)} detection(s) · ${count(summary.stopped)} stopped by the firewall in 7 days`)}`);
136
+ if (summary.categories?.length) console.log(` Tried: ${summary.categories.join(', ')}`);
137
+
138
+ console.log('');
139
+ console.log(` ${ui.c.bold('Why')}${verdict.analyzedAt ? ui.c.dim(` (analyzed ${when(verdict.analyzedAt)})`) : ''}`);
140
+ const signals = [...(verdict.signals?.malicious || []).map((line) => ui.c.red(`+ ${line}`)), ...(verdict.signals?.benign || []).map((line) => ui.c.green(`- ${line}`))];
141
+ if (signals.length) for (const line of signals) console.log(` ${line}`);
142
+ else console.log(` ${ui.c.dim(`No analysis yet: securenow agent verify ${ip}`)}`);
143
+
144
+ if (data.traffic) {
145
+ console.log('');
146
+ console.log(` ${ui.c.bold('Traffic, last 3 days')} ${count(data.traffic.requests)} requests on ${data.traffic.activeDays} day(s)`);
147
+ for (const signal of data.traffic.signals) {
148
+ const value = signal.format === 'percent' ? (signal.value === null ? '—' : `${Math.round(signal.value * 1000) / 10}%`) : count(signal.value);
149
+ const normal = signal.normal !== null && signal.normal !== undefined ? ui.c.dim(` (normal ${Math.round(signal.normal * 1000) / 10}%)`) : '';
150
+ console.log(` ${signal.label.padEnd(30)} ${signal.unusual ? ui.c.red(value) : value}${normal}${signal.unusual ? ui.c.red(' unusual') : ''}`);
151
+ }
152
+ }
153
+
154
+ if (data.alerts?.length) {
155
+ console.log('');
156
+ console.log(` ${ui.c.bold('Detections')}`);
157
+ for (const alert of data.alerts.slice(0, 10)) {
158
+ console.log(` ${ui.c.dim(when(alert.at).padEnd(13))} ${alert.title} ${ui.c.dim([alert.app?.name, `${count(alert.requests)} req`].filter(Boolean).join(' · '))} ${OUTCOMES[alert.outcome] || alert.status}`);
159
+ }
160
+ }
161
+
162
+ if (data.timeline?.length) {
163
+ console.log('');
164
+ console.log(` ${ui.c.bold('Timeline')}`);
165
+ for (const event of data.timeline.slice(0, 12)) console.log(` ${ui.c.dim(when(event.at).padEnd(13))} ${event.text}`);
166
+ }
167
+
168
+ if (data.questions?.length) {
169
+ console.log('');
170
+ console.log(` ${ui.c.bold('Ask the forensics chat')}`);
171
+ for (const question of data.questions) console.log(` ${ui.c.dim('securenow forensics chat')} "${question}"`);
172
+ }
173
+ console.log('');
174
+ }
175
+
176
+ module.exports = { today, attacker };
package/cli.js CHANGED
@@ -359,12 +359,13 @@ const COMMANDS = {
359
359
  sub: {
360
360
  rules: {
361
361
  desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, run, explain, or tune alert rules',
362
- 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'
362
+ usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|run|runs|why|quality|dry-run-query|tune-query|exclusions|versions|version|rollback> [options]\n'
363
363
  + ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]\n'
364
364
  + ' securenow alerts rules change <id> "<what should change>" [--yes|--draft-only] rewrite a rule from English, checked, applied as a new version\n'
365
365
  + ' securenow alerts rules runs <id> [--limit 20] [--since 1h] what each run saw and why it did or didn\'t notify\n'
366
366
  + ' securenow alerts rules run <id> [--no-wait] run a periodic rule now\n'
367
367
  + ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address\n'
368
+ + ' securenow alerts rules quality <id> was the rule right? precision, wrong alerts, what was learned\n'
368
369
  + ' securenow alerts rules request-promotion <id> --reason "..." ask a person to promote a test-mode rule (for agents)',
369
370
  flags: {
370
371
  json: 'Output as JSON',
@@ -456,16 +457,29 @@ const COMMANDS = {
456
457
  },
457
458
  defaultSub: 'status',
458
459
  },
460
+ today: {
461
+ desc: 'What happened, what SecureNow did and what needs you (the dashboard Overview)',
462
+ usage: 'securenow today [--since 24h|7d] [--attackers <n>] [--json]',
463
+ flags: { since: 'Look-back window, 1h to 7d (default 24h)', attackers: 'How many top attackers to list (default 8)', json: 'Output as JSON' },
464
+ run: (a, f) => require('./cli/today').today(a, f),
465
+ },
466
+ attacker: {
467
+ desc: 'Why SecureNow flagged an address: verdict, signals, traffic, detections, timeline, questions to ask',
468
+ usage: 'securenow attacker <ip> [--json]',
469
+ flags: { json: 'Output as JSON' },
470
+ run: (a, f) => require('./cli/today').attacker(a, f),
471
+ },
459
472
  protect: {
460
473
  desc: 'Starter protection per app: the rules every app gets, monitor or block mode',
461
- usage: 'securenow protect <status|enable|disable|block|monitor> [--app <key>] [--json]',
462
- flags: { app: 'App key (defaults to logged-in app)', all: 'With status: every app', rules: 'With status: list each rule', yes: 'Skip the confirmation (block, disable)', json: 'Output as JSON' },
474
+ usage: 'securenow protect <status|enable|disable|block|monitor|learn> [--app <key>] [--json]',
475
+ flags: { app: 'App key (defaults to logged-in app)', all: 'With status or learn: every app', rules: 'With status: list each rule', yes: 'Skip the confirmation (block, disable)', json: 'Output as JSON' },
463
476
  sub: {
464
477
  status: { desc: 'Each app\'s starter protection: mode, rules, alerts and whether it is ready to block', usage: 'securenow protect status [--app <key> | --all] [--rules] [--json]', run: (a, f) => require('./cli/protect').status(a, f) },
465
478
  enable: { desc: 'Turn starter protection on for an app (monitor mode: rules alert, nothing is blocked)', usage: 'securenow protect enable [--app <key>]', run: (a, f) => require('./cli/protect').enable(a, f) },
466
479
  disable: { desc: 'Turn starter protection off for an app; it stays off until someone turns it on', usage: 'securenow protect disable [--app <key>] [--yes]', run: (a, f) => require('./cli/protect').disable(a, f) },
467
480
  block: { desc: 'Turn blocking on (a person signed in): block-ready rules block exploit signatures for 6 hours', usage: 'securenow protect block [--app <key>] [--yes]', run: (a, f) => require('./cli/protect').block(a, f) },
468
481
  monitor: { desc: 'Back to monitor mode: the starter rules alert, nothing is blocked by them', usage: 'securenow protect monitor [--app <key>]', run: (a, f) => require('./cli/protect').monitor(a, f) },
482
+ learn: { desc: 'Detection learning now (it also runs hourly): label the starter rules\' alerts, measure precision, learn safe exclusions', usage: 'securenow protect learn [--app <key> | --all] [--json]', run: (a, f) => require('./cli/protect').learn(a, f) },
469
483
  },
470
484
  defaultSub: 'status',
471
485
  },
@@ -909,7 +923,7 @@ function showHelp(commandName) {
909
923
  'Authentication': ['login', 'logout', 'whoami', 'credentials', 'admin', 'api-key'],
910
924
  'Applications': ['apps', 'app', 'init', 'status'],
911
925
  'Observe': ['traces', 'logs', 'analytics'],
912
- 'Detect & Respond': ['protect', 'agent', 'human', 'review', 'notifications', 'alerts', 'fp'],
926
+ 'Detect & Respond': ['today', 'attacker', 'protect', 'agent', 'human', 'review', 'notifications', 'alerts', 'fp'],
913
927
  'Investigate': ['ip', 'forensics'],
914
928
  'Firewall': ['firewall'],
915
929
  'Remediation': ['automation', 'ratelimit', 'challenge', 'blocklist', 'revoke', 'allowlist', 'trusted'],
package/mcp/catalog.js CHANGED
@@ -1315,6 +1315,34 @@ const TOOLS = [
1315
1315
  app: string('Optional application key; every app the caller can see when left out.'),
1316
1316
  }),
1317
1317
  },
1318
+ {
1319
+ name: 'securenow_overview',
1320
+ title: 'Overview: What Happened and What Needs You',
1321
+ description: 'The dashboard Overview for the last hours (default 24, at most 168, or since an ISO time). Whether each app is covered (data flowing, firewall on, detection monitoring or blocking, with what is missing); attack requests, attackers, requests the firewall stopped, and attacks that got through (whose impact check found possible or confirmed impact; null when none was checked), with attacks per hour; the decisions waiting for a person (block recommendations, coverage problems, apps ready to block, detection promotions); the top attackers (verdict, score, what they tried, apps, requests, whether blocked); and what SecureNow did (the agent\'s actions, automatic blocks, failed probes and rule-fix closes, learned exclusions).',
1322
+ scope: 'notifications:read',
1323
+ readOnly: true,
1324
+ method: 'GET',
1325
+ endpoint: '/overview',
1326
+ queryFields: ['hours', 'since', 'attackers'],
1327
+ inputSchema: objectSchema({
1328
+ hours: number('Look-back window in hours (default 24).', { minimum: 1, maximum: 168 }),
1329
+ since: string('Start of the window as an ISO time, at most 7 days ago; used instead of hours.'),
1330
+ attackers: number('How many top attackers to list (default 8).', { minimum: 1, maximum: 200 }),
1331
+ }),
1332
+ },
1333
+ {
1334
+ name: 'securenow_attacker',
1335
+ title: 'Attacker: Why an Address Was Flagged',
1336
+ description: 'One address across the caller\'s apps: the verdict and risk score with the signals behind them (from the latest verification), reputation, whether it is blocked (where and until when) or never blocked, first and last seen, what it tried, the detections it triggered in 30 days with their outcomes, its block history, a timeline of its events, its traffic over the last 3 days against the apps\' normal (error rate, share of traffic, distinct paths, scanner-path requests, attack payloads answered 2xx), and suggested questions for the forensics chat.',
1337
+ scope: 'notifications:read',
1338
+ readOnly: true,
1339
+ method: 'GET',
1340
+ endpoint: '/overview/attackers/:ip',
1341
+ pathParams: ['ip'],
1342
+ inputSchema: objectSchema({
1343
+ ip: string('IPv4 or IPv6 address.'),
1344
+ }, ['ip']),
1345
+ },
1318
1346
  {
1319
1347
  name: 'securenow_protection_enable',
1320
1348
  title: 'Turn Starter Protection On',
@@ -1345,6 +1373,34 @@ const TOOLS = [
1345
1373
  ...confirmSchema,
1346
1374
  }, ['app', 'confirm', 'reason']),
1347
1375
  },
1376
+ {
1377
+ name: 'securenow_alert_rule_quality',
1378
+ title: 'Alert Rule Quality (Was It Right to Fire?)',
1379
+ description: 'How often an alert rule is right, from its labeled alerts of the last 14 days: correct (an attack, probe or scan, even failed or small), wrong (the rule mistook legitimate use of the app for an attack), unclear; precision = correct / (correct + wrong) over the alerts the rule as it is now would still raise (each alert is replayed through the current conditions and exclusions), target 95%. fixed lists wrong alerts a rule change or exclusion no longer raises; dropped lists correct ones it no longer raises (lost detections). Lists the wrong and unclear alerts with the reason and the legitimate pattern, a sample of correct ones, and what detection learning did: an exclusion it learned for legitimate traffic (with before/after precision) or a "needs a person" analysis when no safe fix passed its safety gate.',
1380
+ scope: 'alerts:read',
1381
+ readOnly: true,
1382
+ method: 'GET',
1383
+ endpoint: '/alert-rules/:id/quality',
1384
+ pathParams: ['id'],
1385
+ inputSchema: objectSchema({
1386
+ id: string('Alert rule id.'),
1387
+ }, ['id']),
1388
+ },
1389
+ {
1390
+ name: 'securenow_protection_learn',
1391
+ title: 'Run Detection Learning Now',
1392
+ description: 'Run detection learning for the account now (it also runs every hour): label the base rules\' recent alerts (was the rule right?), measure each rule\'s precision per app, and learn a safe exclusion for a rule below 95% that keeps mistaking an app\'s legitimate traffic for an attack. A learned exclusion never names an IP and must keep every correct alert and every known attack firing. An account where detection learning isn\'t on yet gets 409 not_rolled_out. Write action; requires confirmation.',
1393
+ scope: 'alerts:write',
1394
+ readOnly: false,
1395
+ confirm: true,
1396
+ method: 'POST',
1397
+ endpoint: '/protection/learn',
1398
+ bodyFields: ['app'],
1399
+ inputSchema: objectSchema({
1400
+ app: string('Optional application key; every app when left out.'),
1401
+ ...confirmSchema,
1402
+ }, ['confirm', 'reason']),
1403
+ },
1348
1404
  {
1349
1405
  name: 'securenow_protection_disable',
1350
1406
  title: 'Turn Starter Protection Off',
@@ -2699,7 +2755,7 @@ const AGENT_PATROL_RUNBOOK = [
2699
2755
  'B. Requires Human: securenow_human_actions_grouped_list, largest groups first (rows you already decided are hidden). Each group lists up to 12 rows; verify and decide 2 to 4 rows of a group, then move to the next group. For each IP row:',
2700
2756
  ' - verify the IP;',
2701
2757
  ' - verdict malicious and blockAllowed: securenow_agent_block_ip with ip, verificationId, notificationId, a reason naming the 2 or 3 decisive signals, confirm true;',
2702
- ' - verdict benign or never_block: when the rule plainly misfired on this kind of legitimate traffic, securenow_human_action_false_positive with the verificationId, on this rule only (no ruleScope, no applyToExisting). On a test-mode rule that will keep misfiring on this IP, add createExclusion with at most a path condition: the server pins the exclusion to this IP and it expires within 7 days. On a prod rule, never createExclusion. Otherwise securenow_human_action_close_no_write with outcome "skipped" (or "other"), the verificationId and a one-line summary of the evidence. An IP the org has blocked stays blocked: lifting a block is for a person;',
2758
+ ' - verdict benign or never_block: a false positive means the rule mistook LEGITIMATE use of the app for an attack. A real probe, scan or attack attempt that is just too small to block (one failed request for /.env, a scanner fetching a page) is not a false positive: the rule was right, so close it with securenow_human_action_close_no_write, outcome "skipped". When the rule plainly misfired on legitimate traffic, securenow_human_action_false_positive with the verificationId, on this rule only (no ruleScope, no applyToExisting). On a test-mode rule that will keep misfiring on this IP, add createExclusion with at most a path condition: the server pins the exclusion to this IP and it expires within 7 days. On a prod rule, never createExclusion. Otherwise securenow_human_action_close_no_write with outcome "skipped" (or "other"), the verificationId and a one-line summary of the evidence. An IP the org has blocked stays blocked: lifting a block is for a person;',
2703
2759
  ' - verdict suspicious: attack-shaped traffic below the block threshold, which a person decides. Do not block. Record your read with securenow_human_action_close_no_write (outcome "ambiguous", the verificationId, missingProof saying what would decide it): the server leaves it on the row as your suggestion, keeps the row for a person and hides it from your lists for a day. If a person should look now (a real attempt that succeeded, a signed-in account under attack), say so in missingProof.',
2704
2760
  ' - verdict insufficient_data (nothing from this IP to confirm anything): do not block. securenow_human_action_close_no_write with outcome "ambiguous", the verificationId, and missingProof saying what would decide it.',
2705
2761
  ' - Rule-level rows (kind case_action: tune_rule, create_exclusion, review_protected_ip): reject vague or wrong ones with securenow_human_case_action_update (status rejected, reason). For tune_rule, do the tuning (section C), then approve it.',
@@ -2709,7 +2765,7 @@ const AGENT_PATROL_RUNBOOK = [
2709
2765
  ' - Benign noise that needs no rule change: close the group with outcome "false_positive" (or "clean").',
2710
2766
  ' - Rows that disagree with each other: decide them one by one (securenow_alert_review_run_resolve), or leave them.',
2711
2767
  'D. When you learn something durable about this org from its people (a correction, a benign actor a person confirmed) or from a rule\'s recurring noise, propose one specific line with securenow_agent_remember; a person approves it before later runs use it. Never propose anything that only telemetry says.',
2712
- 'E. Starter protection: rules named after a threat class ("SQL injection in the URL", "Probe for secrets, source control or backups", ...) are the starter pack every app gets. Their rows follow B and C like any other. When one keeps misfiring on legitimate traffic, prefer an exclusion pinned to the IP (section C) over editing the rule. If calls remain at the end, check securenow_protection_status: when an app says ready to block, say so in your report; only a person turns blocking on.',
2768
+ 'E. Starter protection: rules named after a threat class ("SQL injection in the URL", "Probe for secrets, source control or backups", ...) are the starter pack every app gets. Their rows follow B and C like any other. Where detection learning is on for the account, failed one-off probes of these rules are closed by the server before they reach you, every alert is labeled (was the rule right?) and safe exclusions for legitimate traffic are learned on their own: securenow_alert_rule_quality shows a rule\'s precision and what was learned. When one keeps misfiring on legitimate traffic, prefer an exclusion pinned to the IP (section C) over editing the rule. If calls remain at the end, check securenow_protection_status: when an app says ready to block, say so in your report; only a person turns blocking on.',
2713
2769
  '',
2714
2770
  'Every write needs confirm true and a reason. Reasons and summaries are read by the org\'s people: plain, specific, with the numbers that decided it (requests, paths, status codes, sign-in failures, risk), never vague.',
2715
2771
  '',
@@ -3034,6 +3090,7 @@ const AGENT_PROFILE_TOOLS = new Set([
3034
3090
  'securenow_alert_rule_exclusion_add',
3035
3091
  'securenow_alert_rule_request_promotion',
3036
3092
  'securenow_protection_status',
3093
+ 'securenow_alert_rule_quality',
3037
3094
  'securenow_ip_traces',
3038
3095
  'securenow_traces_show',
3039
3096
  'securenow_logs_for_trace',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.21.0",
3
+ "version": "8.23.0",
4
4
  "mcpName": "ai.securenow/securenow",
5
5
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
6
6
  "type": "commonjs",