securenow 8.14.1 → 8.16.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 CHANGED
@@ -339,6 +339,11 @@ verification plus SQL validation. See `node_modules/securenow/TELEMETRY-SQL.md`
339
339
  timestamp handling, privacy rules, templates, and the full test-to-production lifecycle.
340
340
 
341
341
  ```bash
342
+ # Describe a detection in English (new in 8.16): SecureNow picks an instant rule (every request,
343
+ # as it arrives) or a periodic one (SQL on a schedule), writes it from your app's real data,
344
+ # checks it against that data, and creates it in test mode when you confirm
345
+ npx securenow alerts rules from-text "a sign-in from a new address, then a password change within 5 minutes" --app <app-key>
346
+
342
347
  # Create a custom detection rule from your own SQL (new in 8.1)
343
348
  npx securenow alerts rules create \
344
349
  --name "Auth: magic-link brute force" \
package/README.md CHANGED
@@ -163,6 +163,8 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
163
163
  | `securenow ip <address>` | IP intelligence (geo, abuse, verdict) |
164
164
  | `securenow ip traces <address>` | Traces from an IP |
165
165
  | `securenow forensics "<query>"` | Natural-language forensic query |
166
+ | `securenow forensics chat --app <key> ["<question>"]` | Forensics chat with memory; `--conversation <id>` continues one |
167
+ | `securenow forensics conversations [<id>]` | List forensics conversations, or show one |
166
168
  | `securenow traces` / `traces show <traceId>` | Recent traces / trace spans |
167
169
  | `securenow traces analyze <traceId>` | AI security analysis of a trace |
168
170
  | `securenow logs [--since 6h] [--level error]` | View logs |
package/SKILL-CLI.md CHANGED
@@ -275,6 +275,11 @@ its fields were observed and `alerts rules validate` succeeds. Otherwise label i
275
275
  ```bash
276
276
  securenow alerts # list alert rules (default)
277
277
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
278
+ securenow alerts rules from-text \ # describe a detection in English; checked draft, then create (test mode)
279
+ "one IP failing 10 sign-ins in 5 minutes" \
280
+ --app <key> [--env production] # picks instant or periodic; --instant/--periodic, --draft-only, --yes
281
+ securenow alerts rules create --name "Secrets probe" --apps <key> \
282
+ --conditions '[{"field":"path","operator":"contains","value":"/.env"}]' # an instant rule, no SQL
278
283
  securenow alerts rules validate \ # dry-run a candidate query BEFORE creating any rule
279
284
  --sql @rule.sql --app <key> # no rule id needed — auto-hosts on an existing (system) rule
280
285
  securenow alerts rules create \ # create a custom detection rule from inline SQL
@@ -334,10 +339,20 @@ securenow ip traces <ip-address> --env production # traces originating from this
334
339
  ```bash
335
340
  securenow forensics "show me all SQL injection attempts in the last 24h" --env production
336
341
  securenow forensics query "top 10 IPs by blocked requests" --app my-app --env production
337
- securenow forensics chat --app my-app --env production # interactive forensics chat session
342
+ securenow forensics chat --app my-app --env production # interactive forensics chat session (with memory)
343
+ securenow forensics chat --app my-app "which accounts had failed logins then a success?" # one question, prints the conversation id
344
+ securenow forensics chat --conversation <id> "what else did those IPs do?" # follow up: the chat remembers the earlier answers
345
+ securenow forensics conversations # list conversations (dashboard, CLI and agent ones)
346
+ securenow forensics conversations <id> # show one: questions, SQL, findings, rows
338
347
  securenow forensics library # view saved/template queries
339
348
  ```
340
349
 
350
+ The chat runs discovery queries before answering, shows its SQL, and ends each answer with findings: what the result rows
351
+ show. A follow-up sees the earlier questions, their findings, and the IPs and trace IDs in their results. MCP:
352
+ `securenow_forensics_chat` (pass `conversationId` to continue), `securenow_forensics_conversations_list`,
353
+ `securenow_forensics_conversation_get`. `securenow forensics "<q>"` and `securenow_forensics_query` answer one question
354
+ with no memory.
355
+
341
356
  ---
342
357
 
343
358
  ### Firewall
package/cli/monitor.js CHANGED
@@ -342,6 +342,8 @@ async function notificationsList(args, flags) {
342
342
  const s = ui.spinner('Fetching notifications');
343
343
  try {
344
344
  const query = { limit: flags.limit || 20, page: flags.page || 1 };
345
+ if (flags.search) query.search = String(flags.search);
346
+ if (flags.severity) query.severity = String(flags.severity).toLowerCase();
345
347
  const data = await api.get('/notifications', { query });
346
348
  const notifications = data.notifications || [];
347
349
  const pagination = data.pagination;
@@ -354,7 +356,7 @@ async function notificationsList(args, flags) {
354
356
  ui.c.dim(n.id || n._id || ''),
355
357
  ui.statusBadge(n.read ? 'read' : 'unread'),
356
358
  ui.truncate(n.title || n.message || n.type || '', 50),
357
- n.ip || '—',
359
+ n.primaryIp || n.ip || '—',
358
360
  n.severity ? ui.statusBadge(n.severity) : '—',
359
361
  ui.timeAgo(n.createdAt),
360
362
  ]);
package/cli/security.js CHANGED
@@ -39,6 +39,9 @@ async function alertRulesRoute(args, flags) {
39
39
  if (sub === 'create' || sub === 'add') {
40
40
  return alertRuleCreate(args.slice(1), flags);
41
41
  }
42
+ if (sub === 'from-text' || sub === 'draft') {
43
+ return alertRuleFromText(args.slice(1), flags);
44
+ }
42
45
  if (sub === 'show') {
43
46
  return alertRuleShow(args.slice(1), flags);
44
47
  }
@@ -125,8 +128,8 @@ async function alertRuleSetMode(args, flags, mode) {
125
128
 
126
129
  // Duplicate any rule (including a read-only system template) into a new CUSTOM rule you
127
130
  // own. System rules are view-only examples — fork one with this to enable / edit it. The
128
- // copy defaults to Disabled (opt in with --enable / --status active); SQL, instant.block,
129
- // schedule, scope, and mode are copied from the source.
131
+ // copy defaults to Disabled (opt in with --enable / --status active) and to test mode
132
+ // (--mode prod to arm it); SQL, instant.block, schedule and scope are copied from the source.
130
133
  async function alertRuleDuplicate(args, flags) {
131
134
  requireAuth();
132
135
  const id = args[0];
@@ -221,8 +224,11 @@ async function alertRuleCreate(args, flags) {
221
224
 
222
225
  const queryMappingId = flags['query-mapping-id'] || flags.queryMappingId;
223
226
  const sqlQuery = readSqlArg(flags);
224
- if (!queryMappingId && !sqlQuery) {
225
- ui.error('Provide the detection query: --sql <sql|@file|-> (or reuse one with --query-mapping-id <id>).');
227
+ const instantConditions = flags.conditions ? readJsonArg(flags.conditions, '--conditions') : null;
228
+ if (!queryMappingId && !sqlQuery && !instantConditions) {
229
+ ui.error('Provide the detection query: --sql <sql|@file|-> (or reuse one with --query-mapping-id <id>),');
230
+ ui.error('or an instant rule: --conditions \'[{"field":"path","operator":"contains","value":"/.env"}]\' [--match any|all].');
231
+ ui.info('Or describe it in English: securenow alerts rules from-text "<what to catch>" --app <key>');
226
232
  console.log(ui.c.dim(' Scope app keys in SQL with the __USER_APP_KEYS__ placeholder. The column differs by table:'));
227
233
  console.log(ui.c.dim(" traces: WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__) AND timestamp >= now64(9) - INTERVAL 15 MINUTE"));
228
234
  console.log(ui.c.dim(" logs: WHERE resources_string['service.name'] IN (__USER_APP_KEYS__) AND timestamp >= toUnixTimestamp64Nano(now64() - INTERVAL 15 MINUTE)"));
@@ -239,12 +245,22 @@ async function alertRuleCreate(args, flags) {
239
245
 
240
246
  const body = { name };
241
247
  if (flags.description) body.description = flags.description;
242
- if (queryMappingId) body.queryMappingId = queryMappingId;
248
+ if (instantConditions) {
249
+ // An instant rule: checked against every incoming request, no SQL and no schedule.
250
+ body.executionMode = 'instant';
251
+ body.instant = {
252
+ enabled: true,
253
+ matchMode: flags.match === 'all' ? 'all' : 'any',
254
+ conditions: Array.isArray(instantConditions) ? instantConditions : [instantConditions],
255
+ };
256
+ const env = resolveEnvironment(flags);
257
+ if (env) body.instant.environments = String(env).split(',').map((e) => e.trim()).filter(Boolean);
258
+ } else if (queryMappingId) body.queryMappingId = queryMappingId;
243
259
  else body.sqlQuery = sqlQuery;
244
260
  if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
245
261
  if (flags.category) body.category = flags.category;
246
262
  if (flags.severity) body.severity = String(flags.severity).toLowerCase();
247
- if (flags['execution-mode']) body.executionMode = flags['execution-mode'];
263
+ if (flags['execution-mode'] && !instantConditions) body.executionMode = flags['execution-mode'];
248
264
 
249
265
  if (hasAll) {
250
266
  body.applicationsAll = true;
@@ -646,6 +662,163 @@ function readSqlArg(flags) {
646
662
  return text;
647
663
  }
648
664
 
665
+ // JSON from a flag value, '@file' or '-' (stdin).
666
+ function readJsonArg(raw, label) {
667
+ const text = String(raw);
668
+ const source = text === '-' ? fs.readFileSync(0, 'utf8') : text.startsWith('@') ? fs.readFileSync(text.slice(1), 'utf8') : text;
669
+ try {
670
+ return JSON.parse(source);
671
+ } catch (err) {
672
+ ui.error(`${label} is not valid JSON: ${err.message}`);
673
+ process.exit(1);
674
+ }
675
+ }
676
+
677
+ // ── English → alert rule ──
678
+ // The API drafts the rule from the app's real data (instant or periodic), checks it against that
679
+ // data, and returns the draft. Nothing is created until you confirm; new rules start in test mode.
680
+
681
+ async function waitForDraft(draftId, s) {
682
+ for (let i = 0; i < 90; i++) {
683
+ await new Promise(r => setTimeout(r, 1500));
684
+ const result = await api.get(`/alert-rules/draft/${draftId}`);
685
+ if (result.status !== 'running') return result;
686
+ if (s && i === 6) s.update('Checking the draft against your data');
687
+ }
688
+ return { status: 'failed', error: 'The draft is still running after 2 minutes; try again' };
689
+ }
690
+
691
+ function draftCell(value) {
692
+ if (value == null) return '';
693
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
694
+ return text.length > 40 ? `${text.slice(0, 37)}…` : text;
695
+ }
696
+
697
+ function printDraft(result) {
698
+ const d = result.draft || {};
699
+ const check = result.check || {};
700
+ const kindLabel = d.kind === 'instant' ? ui.c.cyan('Instant rule') : ui.c.cyan('Periodic rule');
701
+ console.log('');
702
+ console.log(` ${kindLabel} ${ui.c.dim('·')} ${d.severity || 'medium'} ${ui.c.dim('·')} ${d.category || 'custom'}`);
703
+ console.log(` ${ui.c.bold(d.name || '')}`);
704
+ if (d.summary) console.log(` ${d.summary}`);
705
+ if (d.whyThisKind) console.log(ui.c.dim(` Why ${d.kind}: ${d.whyThisKind}`));
706
+ console.log('');
707
+ if (d.kind === 'instant') {
708
+ const conditions = d.instant?.conditions || [];
709
+ const envs = d.instant?.environments || [];
710
+ console.log(` Watches every request, as it arrives${envs.length ? `, in ${envs.join(', ')}` : ', in every environment'}`);
711
+ console.log(` When ${d.instant?.matchMode === 'all' ? 'all of' : 'any of'}`);
712
+ for (const c of conditions) console.log(` ${c.field} ${ui.c.dim(c.operator)} ${c.value}`);
713
+ } else {
714
+ const parts = [d.schedule?.description || d.schedule?.cronExpression];
715
+ if (d.windowMinutes) parts.push(`window ${d.windowMinutes} min`);
716
+ if (d.threshold) parts.push(d.threshold);
717
+ console.log(` Schedule ${parts.filter(Boolean).join(ui.c.dim(' · '))}`);
718
+ console.log(' SQL');
719
+ for (const line of String(d.sqlQuery || '').split('\n')) console.log(ui.c.dim(` ${line}`));
720
+ }
721
+ console.log('');
722
+ console.log(` Check ${check.note || ''}`);
723
+ const rows = check.rows || [];
724
+ if (rows.length) {
725
+ const cols = Object.keys(rows[0]).slice(0, 6);
726
+ console.log('');
727
+ ui.table(cols, rows.slice(0, 8).map((row) => cols.map((c) => draftCell(row[c]))));
728
+ }
729
+ for (const w of check.warnings || []) ui.warn(w);
730
+ }
731
+
732
+ function draftToCreateBody(result, flags) {
733
+ const d = result.draft || {};
734
+ const body = {
735
+ name: flags.name ? String(flags.name) : d.name,
736
+ description: d.summary || '',
737
+ nlpQuery: d.nlpQuery,
738
+ category: d.category,
739
+ severity: flags.severity ? String(flags.severity).toLowerCase() : d.severity,
740
+ applicationsAll: false,
741
+ applications: [result.applicationKey],
742
+ executionMode: d.executionMode,
743
+ };
744
+ if (d.executionMode === 'instant') body.instant = d.instant;
745
+ else {
746
+ body.sqlQuery = d.sqlQuery;
747
+ body.schedule = d.schedule;
748
+ }
749
+ if (flags['no-throttle']) body.throttle = { enabled: false, minutes: 0 };
750
+ else if (flags['throttle-minutes']) body.throttle = { enabled: true, minutes: Number(flags['throttle-minutes']) };
751
+ return body;
752
+ }
753
+
754
+ async function alertRuleFromText(args, flags) {
755
+ requireAuth();
756
+ const text = (args.join(' ').trim() || (typeof flags.text === 'string' ? flags.text : '')).trim();
757
+ const appKey = resolveApp(flags);
758
+ if (!text || !appKey) {
759
+ ui.error('Usage: securenow alerts rules from-text "<what the rule should catch>" --app <key> [--env <environment>]');
760
+ console.log(ui.c.dim(' [--instant | --periodic] [--name "..."] [--severity high] [--yes] [--draft-only] [--json]'));
761
+ console.log(ui.c.dim(' Example: securenow alerts rules from-text "one IP failing 10 sign-ins in 5 minutes" --app <key>'));
762
+ process.exit(1);
763
+ }
764
+
765
+ const body = { text, applicationKey: appKey };
766
+ const environment = resolveEnvironment(flags);
767
+ if (environment) body.environment = environment;
768
+ if (flags.instant || flags.kind === 'instant') body.kind = 'instant';
769
+ else if (flags.periodic || flags.kind === 'periodic') body.kind = 'periodic';
770
+
771
+ const s = ui.spinner('Drafting the rule from your app\'s data');
772
+ let result;
773
+ try {
774
+ const started = await api.post('/alert-rules/draft', body);
775
+ result = await waitForDraft(started.draftId, s);
776
+ } catch (err) {
777
+ s.fail('Failed to draft the rule');
778
+ throw err;
779
+ }
780
+ if (result.status !== 'complete') {
781
+ s.fail(result.error || 'Could not draft the rule');
782
+ process.exit(1);
783
+ }
784
+ s.stop('Draft ready');
785
+
786
+ if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
787
+ printDraft(result);
788
+ console.log('');
789
+ if (flags['draft-only']) {
790
+ ui.info('Draft only: nothing was created. Drop --draft-only to create it.');
791
+ return;
792
+ }
793
+ if (!flags.yes) {
794
+ const ok = await ui.confirm('Create this rule? It starts in test mode: it alerts, but blocks nothing');
795
+ if (!ok) { ui.info('Nothing created'); return; }
796
+ }
797
+
798
+ const c = ui.spinner('Creating the rule');
799
+ let data;
800
+ try {
801
+ data = await api.post('/alert-rules', draftToCreateBody(result, flags));
802
+ } catch (err) {
803
+ c.fail('Failed to create the rule');
804
+ throw err;
805
+ }
806
+ const r = data.alertRule || data;
807
+ const id = r._id || r.id || '';
808
+ c.stop('Rule created');
809
+ if (flags.json) { ui.json({ draft: result.draft, check: result.check, alertRule: r }); return; }
810
+ console.log('');
811
+ ui.keyValue([
812
+ ['ID', id || '-'],
813
+ ['Name', r.name || '-'],
814
+ ['Kind', r.executionMode === 'instant' ? 'instant (every request, as it arrives)' : `periodic (${r.schedule?.description || r.schedule?.cronExpression || '-'})`],
815
+ ['Mode', `${r.mode || 'test'} (alerts only, no blocking)`],
816
+ ['Application', (r.applications || []).join(', ') || '-'],
817
+ ]);
818
+ console.log('');
819
+ ui.success(`Watching. Promote it when it has proved itself: securenow alerts rules promote ${id}`);
820
+ }
821
+
649
822
  async function alertRuleTest(args, flags) {
650
823
  requireAuth();
651
824
  const id = args[0];
@@ -1509,24 +1682,124 @@ async function forensicsLibrary(args, flags) {
1509
1682
 
1510
1683
  // ── Forensics Chat ──
1511
1684
 
1512
- async function forensicsChat(args, flags) {
1513
- requireAuth();
1685
+ const CHAT_PRINT_ROWS = 25;
1686
+
1687
+ // Log timestamps come back as nanosecond (or microsecond) epochs when a query returns
1688
+ // min(timestamp) and the like from the logs table; print them as times.
1689
+ function chatCell(value) {
1690
+ const s = String(value ?? '');
1691
+ if (/^\d{16,19}$/.test(s)) {
1692
+ const n = Number(s);
1693
+ const ms = n >= 1e18 && n < 4e18 ? n / 1e6 : n >= 1e15 && n < 4e15 ? n / 1e3 : null;
1694
+ if (ms != null) return new Date(ms).toISOString().replace('T', ' ').replace('Z', '');
1695
+ }
1696
+ return typeof value === 'object' && value !== null ? JSON.stringify(value) : s;
1697
+ }
1698
+
1699
+ // The API answers a chat message with 202 and works in the background; poll until
1700
+ // the reply settles (complete or awaiting_confirmation) or about five minutes pass.
1701
+ async function waitForChatReply(conversationId, environment, s) {
1702
+ for (let i = 0; i < 150; i++) {
1703
+ await new Promise(r => setTimeout(r, 2000));
1704
+ const result = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
1705
+ if (result.status !== 'processing') return { ...result, conversationId };
1706
+ if (result._progress?.action && s) s.update(result._progress.action);
1707
+ }
1708
+ return { conversationId, status: 'processing' };
1709
+ }
1710
+
1711
+ async function sendChatMessage(body, environment, s) {
1712
+ const chatRes = await api.post('/forensics/chat', body);
1713
+ if (chatRes.status !== 'processing') {
1714
+ return { conversationId: chatRes.conversationId, status: 'complete', message: chatRes.message };
1715
+ }
1716
+ return waitForChatReply(chatRes.conversationId, environment, s);
1717
+ }
1718
+
1719
+ async function confirmChatQuery(conversationId, environment, s) {
1720
+ await api.post(`/forensics/chat/confirm/${conversationId}`, { environment });
1721
+ return waitForChatReply(conversationId, environment, s);
1722
+ }
1723
+
1724
+ // Resolve what a chat talks to: an existing conversation (--conversation <id>,
1725
+ // which carries its own app and environment) or a new one scoped to --app.
1726
+ async function resolveChatTarget(flags) {
1727
+ if (typeof flags.conversation === 'string' && flags.conversation) {
1728
+ const data = await api.get(`/forensics/conversations/${flags.conversation}`);
1729
+ const conv = data.conversation;
1730
+ return {
1731
+ conversationId: conv.id,
1732
+ title: conv.title,
1733
+ label: conv.applicationName || conv.applicationKey || conv.applicationId,
1734
+ appKey: conv.applicationKey,
1735
+ environment: conv.environment || 'production',
1736
+ messageCount: (conv.messages || []).length,
1737
+ };
1738
+ }
1514
1739
  const resolved = await resolveAppId(flags);
1515
1740
  if (!resolved) {
1516
- ui.error('App required. Usage: securenow forensics chat --app <key>');
1741
+ ui.error('App required. Usage: securenow forensics chat --app <key>, or --conversation <id> to continue one.');
1517
1742
  ui.info('Set a default with: securenow apps default <key>');
1518
1743
  process.exit(1);
1519
1744
  }
1745
+ return {
1746
+ conversationId: null,
1747
+ label: resolved.name || resolved.key,
1748
+ appKey: resolved.key,
1749
+ environment: resolveEnvironment(flags, 'production'),
1750
+ };
1751
+ }
1520
1752
 
1521
- console.log('');
1522
- ui.heading(`Forensics Chat — ${resolved.name || resolved.key}`);
1523
- console.log(ui.c.dim(` App: ${resolved.key} (${resolved.id})`));
1524
- console.log(ui.c.dim(' Type your question, or "exit" to quit.\n'));
1753
+ async function forensicsChat(args, flags) {
1754
+ requireAuth();
1755
+ // `--json "<question>"` parses the question as the flag's value; give it back.
1756
+ const words = [...args];
1757
+ for (const key of ['json', 'yes', 'force']) {
1758
+ if (typeof flags[key] === 'string') { words.push(flags[key]); flags[key] = true; }
1759
+ }
1760
+ const target = await resolveChatTarget(flags);
1761
+ const { environment } = target;
1762
+ let conversationId = target.conversationId;
1763
+ const question = words.join(' ').trim();
1764
+
1765
+ // One question, one reply, then exit: scriptable, and what an agent without MCP uses.
1766
+ if (question) {
1767
+ const s = flags.json ? null : ui.spinner('Thinking');
1768
+ const body = { message: question, environment };
1769
+ if (conversationId) body.conversationId = conversationId;
1770
+ else body.applicationKey = target.appKey;
1771
+ let reply = await sendChatMessage(body, environment, s);
1772
+ if (reply.status === 'awaiting_confirmation' && (flags.yes || flags.force)) {
1773
+ if (s) s.update('Running the large query');
1774
+ reply = await confirmChatQuery(reply.conversationId, environment, s);
1775
+ }
1776
+ if (flags.json) { ui.json(reply); return; }
1777
+ if (reply.status === 'processing') {
1778
+ s.fail('Still working');
1779
+ ui.info(`See the reply later: securenow forensics conversations ${reply.conversationId}`);
1780
+ return;
1781
+ }
1782
+ s.stop('Done');
1783
+ printChatMessage(reply.message);
1784
+ console.log('');
1785
+ if (reply.status === 'awaiting_confirmation') {
1786
+ ui.info('Large query held back. Add --yes to run it, or ask a narrower question.');
1787
+ }
1788
+ ui.info(`Conversation ${reply.conversationId}`);
1789
+ console.log(ui.c.dim(` Follow up: securenow forensics chat --conversation ${reply.conversationId} "<question>"`));
1790
+ console.log('');
1791
+ return;
1792
+ }
1525
1793
 
1526
- const environment = resolveEnvironment(flags, 'production');
1794
+ console.log('');
1795
+ ui.heading(`Forensics Chat — ${target.label}`);
1796
+ if (conversationId) {
1797
+ console.log(ui.c.dim(` Continuing: ${target.title} (${target.messageCount} messages)`));
1798
+ } else {
1799
+ console.log(ui.c.dim(` App: ${target.appKey}`));
1800
+ }
1527
1801
  console.log(ui.c.dim(` Env: ${environment}`));
1528
-
1529
- let conversationId = null;
1802
+ console.log(ui.c.dim(' Type your question, or "exit" to quit.\n'));
1530
1803
 
1531
1804
  const readline = require('readline');
1532
1805
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
@@ -1541,57 +1814,37 @@ async function forensicsChat(args, flags) {
1541
1814
  if (!message || message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
1542
1815
  console.log('');
1543
1816
  ui.info('Chat ended.');
1817
+ if (conversationId) console.log(ui.c.dim(` Continue later: securenow forensics chat --conversation ${conversationId}`));
1544
1818
  break;
1545
1819
  }
1546
1820
 
1547
1821
  const s = ui.spinner('Thinking');
1548
1822
  try {
1549
- const body = { message, applicationKey: resolved.key, environment };
1823
+ const body = { message, environment };
1550
1824
  if (conversationId) body.conversationId = conversationId;
1825
+ else body.applicationKey = target.appKey;
1551
1826
 
1552
- const chatRes = await api.post('/forensics/chat', body);
1553
- conversationId = chatRes.conversationId;
1554
-
1555
- if (chatRes.status === 'processing') {
1556
- let result;
1557
- for (let i = 0; i < 150; i++) {
1558
- await new Promise(r => setTimeout(r, 2000));
1559
- result = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
1560
- if (result.status === 'complete' || result.status === 'failed' || result.status === 'awaiting_confirmation') break;
1561
- const progress = result._progress;
1562
- if (progress?.action) s.update(progress.action);
1563
- }
1827
+ const reply = await sendChatMessage(body, environment, s);
1828
+ conversationId = reply.conversationId;
1564
1829
 
1565
- if (!result || result.status === 'processing') {
1566
- s.fail('Response timed out');
1567
- continue;
1568
- }
1830
+ if (reply.status === 'processing') {
1831
+ s.fail('Response timed out');
1832
+ continue;
1833
+ }
1569
1834
 
1570
- s.stop('Done');
1571
- printChatMessage(result.message);
1572
-
1573
- if (result.status === 'awaiting_confirmation') {
1574
- const proceed = await ui.confirm('Proceed with this query?');
1575
- if (proceed) {
1576
- const cs = ui.spinner('Executing query');
1577
- await api.post(`/forensics/chat/confirm/${conversationId}`, { environment });
1578
- let confirmResult;
1579
- for (let i = 0; i < 90; i++) {
1580
- await new Promise(r => setTimeout(r, 2000));
1581
- confirmResult = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
1582
- if (confirmResult.status !== 'processing') break;
1583
- }
1584
- cs.stop('Done');
1585
- if (confirmResult?.message) printChatMessage(confirmResult.message);
1586
- } else {
1587
- ui.info('Query skipped. Ask a different question or refine.');
1588
- }
1835
+ s.stop('Done');
1836
+ printChatMessage(reply.message);
1837
+
1838
+ if (reply.status === 'awaiting_confirmation') {
1839
+ const proceed = await ui.confirm('Proceed with this query?');
1840
+ if (proceed) {
1841
+ const cs = ui.spinner('Executing query');
1842
+ const confirmed = await confirmChatQuery(conversationId, environment, cs);
1843
+ cs.stop('Done');
1844
+ if (confirmed?.message) printChatMessage(confirmed.message);
1845
+ } else {
1846
+ ui.info('Query skipped. Ask a different question or refine.');
1589
1847
  }
1590
- } else if (chatRes.message) {
1591
- s.stop('Done');
1592
- printChatMessage(chatRes.message);
1593
- } else {
1594
- s.stop('Done');
1595
1848
  }
1596
1849
  } catch (err) {
1597
1850
  s.fail('Error');
@@ -1604,9 +1857,61 @@ async function forensicsChat(args, flags) {
1604
1857
  }
1605
1858
  }
1606
1859
 
1607
- function printChatMessage(msg) {
1860
+ async function forensicsConversations(args, flags) {
1861
+ requireAuth();
1862
+ const id = args[0];
1863
+
1864
+ if (id) {
1865
+ const s = flags.json ? null : ui.spinner('Fetching conversation');
1866
+ const data = await api.get(`/forensics/conversations/${id}`);
1867
+ const conv = data.conversation;
1868
+ if (flags.json) { ui.json(conv); return; }
1869
+ s.stop(conv.title);
1870
+ console.log(ui.c.dim(` ${conv.applicationName || conv.applicationKey} · ${conv.environment} · ${(conv.messages || []).length} messages · ${conv.id}`));
1871
+ for (const msg of conv.messages || []) {
1872
+ if (msg.role === 'user') {
1873
+ console.log(`\n ${ui.c.cyan('you >')} ${msg.content}`);
1874
+ } else {
1875
+ printChatMessage(msg, { maxRows: 10 });
1876
+ }
1877
+ }
1878
+ console.log('');
1879
+ console.log(ui.c.dim(` Continue: securenow forensics chat --conversation ${conv.id} "<question>"`));
1880
+ console.log('');
1881
+ return;
1882
+ }
1883
+
1884
+ const s = flags.json ? null : ui.spinner('Fetching conversations');
1885
+ const query = {};
1886
+ if (flags.app) {
1887
+ const resolved = await resolveAppId(flags);
1888
+ if (resolved) query.applicationId = resolved.id;
1889
+ }
1890
+ const data = await api.get('/forensics/conversations', { query });
1891
+ const list = data.conversations || [];
1892
+ if (flags.json) { ui.json(list); return; }
1893
+ s.stop(`Found ${list.length} conversation${list.length !== 1 ? 's' : ''}`);
1894
+ console.log('');
1895
+ ui.table(['ID', 'Title', 'App', 'Env', 'Msgs', 'Updated'], list.map(c => [
1896
+ ui.c.dim(c.id),
1897
+ ui.truncate(c.title || '', 48),
1898
+ c.applicationName || '—',
1899
+ c.environment || '—',
1900
+ String(c.messageCount ?? 0),
1901
+ ui.timeAgo(c.updatedAt),
1902
+ ]));
1903
+ console.log('');
1904
+ }
1905
+
1906
+ function printChatMessage(msg, { maxRows = CHAT_PRINT_ROWS } = {}) {
1608
1907
  if (!msg) return;
1609
1908
  console.log('');
1909
+ if (msg.reasoning) {
1910
+ console.log(ui.c.dim(` plan: ${msg.reasoning}`));
1911
+ }
1912
+ if (Array.isArray(msg.discoverySteps) && msg.discoverySteps.length > 0) {
1913
+ console.log(ui.c.dim(` ${msg.discoverySteps.length} discovery quer${msg.discoverySteps.length !== 1 ? 'ies' : 'y'} run first`));
1914
+ }
1610
1915
  if (msg.content) {
1611
1916
  console.log(` ${ui.c.green('ai >')} ${msg.content}`);
1612
1917
  }
@@ -1616,11 +1921,18 @@ function printChatMessage(msg) {
1616
1921
  console.log(`\n ${ui.c.dim(msg.sqlQuery)}\n`);
1617
1922
  }
1618
1923
  if (msg.results && Array.isArray(msg.results) && msg.results.length > 0) {
1619
- ui.subheading(`Results (${msg.resultCount ?? msg.results.length} rows)`);
1924
+ const total = msg.resultCount ?? msg.results.length;
1925
+ ui.subheading(`Results (${total} rows)`);
1620
1926
  console.log('');
1621
1927
  const headers = Object.keys(msg.results[0]);
1622
- const rows = msg.results.map(row => headers.map(h => String(row[h] ?? '')));
1928
+ const rows = msg.results.slice(0, maxRows).map(row => headers.map(h => chatCell(row[h])));
1623
1929
  ui.table(headers, rows);
1930
+ if (msg.results.length > maxRows) {
1931
+ console.log(ui.c.dim(` … ${msg.results.length - maxRows} more rows (--json for all)`));
1932
+ }
1933
+ }
1934
+ if (msg.findings) {
1935
+ console.log(`\n ${ui.c.cyan('findings >')} ${msg.findings}`);
1624
1936
  }
1625
1937
  if (msg.estimation) {
1626
1938
  const est = msg.estimation;
@@ -1857,6 +2169,7 @@ module.exports = {
1857
2169
  trustedRemove,
1858
2170
  forensicsQuery,
1859
2171
  forensicsChat,
2172
+ forensicsConversations,
1860
2173
  forensicsLibrary,
1861
2174
  ipLookup,
1862
2175
  ipTraces,
package/cli.js CHANGED
@@ -240,7 +240,7 @@ const COMMANDS = {
240
240
  desc: 'Manage notifications',
241
241
  usage: 'securenow notifications <subcommand> [options]',
242
242
  sub: {
243
- list: { desc: 'List notifications', flags: { limit: 'Max results', page: 'Page number' }, run: (a, f) => require('./cli/monitor').notificationsList(a, f) },
243
+ list: { desc: 'List notifications', flags: { limit: 'Max results', page: 'Page number', search: 'Only notifications whose title, description or IP contains this text (e.g. a rule name)', severity: 'critical | high | medium | low' }, run: (a, f) => require('./cli/monitor').notificationsList(a, f) },
244
244
  read: { desc: 'Mark notification as read', usage: 'securenow notifications read <id>', run: (a, f) => require('./cli/monitor').notificationsRead(a, f) },
245
245
  'read-all': { desc: 'Mark all as read', run: () => require('./cli/monitor').notificationsReadAll() },
246
246
  unread: { desc: 'Show unread count', run: () => require('./cli/monitor').notificationsUnread() },
@@ -311,11 +311,18 @@ const COMMANDS = {
311
311
  usage: 'securenow alerts <subcommand> [options]',
312
312
  sub: {
313
313
  rules: {
314
- desc: 'Create, list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, or tune alert rules',
315
- usage: 'securenow alerts rules <list|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions> [options]',
314
+ desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, 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]',
316
317
  flags: {
317
318
  json: 'Output as JSON',
318
- name: 'With create: rule name',
319
+ env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
320
+ instant: 'With from-text: ask for an instant rule (every request, as it arrives)',
321
+ periodic: 'With from-text: ask for a periodic rule (SQL on a schedule)',
322
+ 'draft-only': 'With from-text: show the checked draft, create nothing',
323
+ conditions: 'With create: instant-rule conditions as JSON, @file or - ([{"field":"path","operator":"contains","value":"/.env"}])',
324
+ match: 'With create --conditions: any (default) | all',
325
+ name: 'With create/from-text: rule name',
319
326
  description: 'With create: rule description',
320
327
  nlp: 'With create: plain-English intent stored on the query',
321
328
  text: 'Alias for --nlp',
@@ -585,7 +592,8 @@ const COMMANDS = {
585
592
  usage: 'securenow forensics <query> [--app <key>]',
586
593
  sub: {
587
594
  query: { desc: 'Run a forensic query', flags: { app: 'App key to scope query', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/security').forensicsQuery(a, f) },
588
- chat: { desc: 'Interactive forensics chat (scoped to an app)', usage: 'securenow forensics chat --app <key> [--env production]', flags: { app: 'App key to chat with', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/security').forensicsChat(a, f) },
595
+ chat: { desc: 'Forensics chat with memory: interactive, or one question with "<question>"', usage: 'securenow forensics chat [--app <key> | --conversation <id>] ["<question>"] [--json]', flags: { app: 'App key for a new conversation', conversation: 'Continue this conversation (from the dashboard, the CLI or an agent)', env: 'Environment of a new conversation (default: production)', environment: 'Alias for --env', yes: 'Run a query the chat held back as large', json: 'Print the raw reply (one-question mode)' }, run: (a, f) => require('./cli/security').forensicsChat(a, f) },
596
+ conversations: { desc: 'List forensics chat conversations, or show one', usage: 'securenow forensics conversations [<id>] [--app <key>] [--json]', flags: { app: 'Only conversations about this app', json: 'Output as JSON' }, run: (a, f) => require('./cli/security').forensicsConversations(a, f) },
589
597
  library: { desc: 'View saved queries', run: (a, f) => require('./cli/security').forensicsLibrary(a, f) },
590
598
  },
591
599
  defaultAction: (a, f) => require('./cli/security').forensicsQuery(a, f),
package/mcp/catalog.js CHANGED
@@ -845,7 +845,7 @@ const TOOLS = [
845
845
  {
846
846
  name: 'securenow_forensics_query',
847
847
  title: 'Run Forensics Query',
848
- description: 'Run a natural-language forensic query over your traffic and return the result rows. Waits up to 30 seconds; a query still running after that returns its jobId with status processing. Write scope because it creates an async query job.',
848
+ description: 'Run one natural-language forensic query over your traffic and return the result rows. One question, no memory of earlier questions; for an investigation with follow-ups use securenow_forensics_chat. Waits up to 30 seconds; a query still running after that returns its jobId with status processing. Write scope because it creates an async query job.',
849
849
  scope: 'forensics:write',
850
850
  readOnly: false,
851
851
  confirm: true,
@@ -890,6 +890,93 @@ const TOOLS = [
890
890
  endpoint: '/forensics/query-library',
891
891
  inputSchema: objectSchema({}),
892
892
  },
893
+ {
894
+ name: 'securenow_forensics_chat',
895
+ title: 'Ask the Forensics Chat',
896
+ description: 'Ask the SecureNow forensics chat a question about one app\'s traces, logs, errors and custom events, in plain English. Without conversationId it starts a new conversation (applicationKey or applicationId required); with conversationId it continues that conversation, including ones started in the dashboard or the CLI, and the chat sees the earlier questions, their SQL, findings, and the IPs and trace IDs in their results. The reply has the chat\'s reasoning, any discovery queries, the SQL, up to 100 result rows and findings: what the rows show. Read-only over telemetry. Waits up to 90 seconds; a reply still running after that comes back with status processing (use securenow_forensics_chat_status). Status awaiting_confirmation means the query is large: run it with securenow_forensics_chat_confirm or ask a narrower question.',
897
+ scope: 'forensics:write',
898
+ readOnly: false,
899
+ confirm: true,
900
+ method: 'POST',
901
+ endpoint: '/forensics/chat',
902
+ bodyFields: ['message', 'conversationId', 'applicationKey', 'applicationId', 'environment'],
903
+ poll: {
904
+ tool: 'securenow_forensics_chat_status',
905
+ idField: 'conversationId',
906
+ pending: ['processing'],
907
+ timeoutMs: 90000,
908
+ intervalMs: 2000,
909
+ },
910
+ inputSchema: objectSchema({
911
+ message: string('The question, in plain English. Follow-ups can refer to earlier answers ("those IPs", "that session").'),
912
+ conversationId: string('Continue this conversation. Omit to start a new one.'),
913
+ applicationKey: string('App key the new conversation is scoped to. Not needed with conversationId.'),
914
+ applicationId: string('App database id, instead of applicationKey.'),
915
+ environment: string('Environment of a new conversation: production (default), staging, preview, local or test. Omit it when continuing: a conversation keeps its environment.'),
916
+ ...confirmSchema,
917
+ }, ['message', 'confirm', 'reason']),
918
+ },
919
+ {
920
+ name: 'securenow_forensics_chat_status',
921
+ title: 'Forensics Chat Reply',
922
+ description: 'Get the latest reply of a forensics chat conversation: status (processing, complete or awaiting_confirmation), progress while processing, then the reasoning, SQL, result rows and findings.',
923
+ scope: 'forensics:read',
924
+ readOnly: true,
925
+ method: 'GET',
926
+ endpoint: '/forensics/chat/status/:conversationId',
927
+ pathParams: ['conversationId'],
928
+ inputSchema: objectSchema({
929
+ conversationId: string('Forensics chat conversation id.'),
930
+ }, ['conversationId']),
931
+ },
932
+ {
933
+ name: 'securenow_forensics_chat_confirm',
934
+ title: 'Run a Large Forensics Query',
935
+ description: 'Run the query a forensics chat held back as large (status awaiting_confirmation), after the user agrees. Returns the rows and findings like securenow_forensics_chat.',
936
+ scope: 'forensics:write',
937
+ readOnly: false,
938
+ confirm: true,
939
+ method: 'POST',
940
+ endpoint: '/forensics/chat/confirm/:conversationId',
941
+ pathParams: ['conversationId'],
942
+ poll: {
943
+ tool: 'securenow_forensics_chat_status',
944
+ idField: 'conversationId',
945
+ pending: ['processing'],
946
+ timeoutMs: 90000,
947
+ intervalMs: 2000,
948
+ },
949
+ inputSchema: objectSchema({
950
+ conversationId: string('Forensics chat conversation id.'),
951
+ ...confirmSchema,
952
+ }, ['conversationId', 'confirm', 'reason']),
953
+ },
954
+ {
955
+ name: 'securenow_forensics_conversations_list',
956
+ title: 'List Forensics Conversations',
957
+ description: 'List your forensics chat conversations, newest first (up to 100): id, title, app, environment, message count and last update. Includes conversations started in the dashboard and the CLI.',
958
+ scope: 'forensics:read',
959
+ readOnly: true,
960
+ method: 'GET',
961
+ endpoint: '/forensics/conversations',
962
+ queryFields: ['applicationId'],
963
+ inputSchema: objectSchema({
964
+ applicationId: string('Only conversations about this app database id.'),
965
+ }),
966
+ },
967
+ {
968
+ name: 'securenow_forensics_conversation_get',
969
+ title: 'Get Forensics Conversation',
970
+ description: 'Get a whole forensics chat conversation: every question and reply with its reasoning, SQL, findings and up to 100 result rows. Use it to read an investigation someone started in the dashboard before continuing it with securenow_forensics_chat.',
971
+ scope: 'forensics:read',
972
+ readOnly: true,
973
+ method: 'GET',
974
+ endpoint: '/forensics/conversations/:id',
975
+ pathParams: ['id'],
976
+ inputSchema: objectSchema({
977
+ id: string('Forensics chat conversation id.'),
978
+ }, ['id']),
979
+ },
893
980
  {
894
981
  name: 'securenow_analytics_summary',
895
982
  title: 'Analytics Summary',
@@ -1291,6 +1378,70 @@ const TOOLS = [
1291
1378
  ...confirmSchema,
1292
1379
  }, ['text', 'confirm', 'reason']),
1293
1380
  },
1381
+ {
1382
+ name: 'securenow_alert_rule_draft',
1383
+ title: 'Draft an Alert Rule From English',
1384
+ description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. Waits up to 120 seconds.',
1385
+ scope: 'alerts:write',
1386
+ readOnly: false,
1387
+ confirm: true,
1388
+ method: 'POST',
1389
+ endpoint: '/alert-rules/draft',
1390
+ bodyFields: ['text', 'applicationKey', 'environment', 'kind'],
1391
+ poll: {
1392
+ tool: 'securenow_alert_rule_draft_status',
1393
+ idField: 'draftId',
1394
+ pending: ['running'],
1395
+ timeoutMs: 120000,
1396
+ intervalMs: 2000,
1397
+ },
1398
+ inputSchema: objectSchema({
1399
+ text: string('What the rule should catch, in plain English, e.g. "one IP failing 10 sign-ins in 5 minutes" or "anyone requesting /.env or /.git".'),
1400
+ applicationKey: string('App key the rule watches; the draft is checked against its data.'),
1401
+ environment: string('Only watch this environment (e.g. production). Omit to watch every environment.'),
1402
+ kind: string('Optional: instant or periodic. Omit to let the drafter choose.'),
1403
+ ...confirmSchema,
1404
+ }, ['text', 'applicationKey', 'confirm', 'reason']),
1405
+ },
1406
+ {
1407
+ name: 'securenow_alert_rule_draft_status',
1408
+ title: 'Alert Rule Draft Result',
1409
+ description: 'Get an alert rule draft started by securenow_alert_rule_draft: status (running, complete or failed), then the draft (kind, name, summary, SQL and schedule, or instant conditions) and the check against the app\'s data.',
1410
+ scope: 'alerts:read',
1411
+ readOnly: true,
1412
+ method: 'GET',
1413
+ endpoint: '/alert-rules/draft/:draftId',
1414
+ pathParams: ['draftId'],
1415
+ inputSchema: objectSchema({
1416
+ draftId: string('Draft id from securenow_alert_rule_draft.'),
1417
+ }, ['draftId']),
1418
+ },
1419
+ {
1420
+ name: 'securenow_alert_rule_create',
1421
+ title: 'Create Alert Rule (Test Mode)',
1422
+ description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (rows without an ip never alert). Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
1423
+ scope: 'alerts:write',
1424
+ readOnly: false,
1425
+ confirm: true,
1426
+ method: 'POST',
1427
+ endpoint: '/alert-rules',
1428
+ bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle'],
1429
+ inputSchema: objectSchema({
1430
+ name: string('Rule name.'),
1431
+ description: string('What it catches, in a sentence.'),
1432
+ nlpQuery: string('The detection idea in plain English (kept with the rule).'),
1433
+ category: string('One word, e.g. authentication, scanning, exfiltration.'),
1434
+ severity: string('low, medium, high or critical.'),
1435
+ applications: { type: 'array', items: { type: 'string' }, description: 'App keys the rule watches.' },
1436
+ applicationsAll: boolean('true watches every app instead of applications.'),
1437
+ executionMode: string('scheduled (periodic SQL, the default) or instant (conditions on every request).'),
1438
+ sqlQuery: string('Periodic rules: the detection SELECT.'),
1439
+ schedule: { type: 'object', additionalProperties: true, description: 'Periodic rules: {cronExpression: "* * * * *", description: "Every minute"}. Default every 15 minutes.' },
1440
+ instant: { type: 'object', additionalProperties: true, description: 'Instant rules: {matchMode: "any" | "all", conditions: [{field, operator, value}], environments: ["staging"] (optional; omit to watch every environment)}.' },
1441
+ throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per rule per window.' },
1442
+ ...confirmSchema,
1443
+ }, ['name', 'confirm', 'reason']),
1444
+ },
1294
1445
  {
1295
1446
  name: 'securenow_alert_rules_list',
1296
1447
  title: 'List Alert Rules',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.14.1",
3
+ "version": "8.16.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",