securenow 8.14.0 → 8.15.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 +2 -0
- package/SKILL-CLI.md +11 -1
- package/cli/automation.js +1 -1
- package/cli/challenges.js +1 -1
- package/cli/fp.js +1 -1
- package/cli/monitor.js +1 -1
- package/cli/rateLimits.js +24 -8
- package/cli/security.js +204 -64
- package/cli/ui.js +1 -0
- package/cli.js +2 -1
- package/firewall.js +1 -1
- package/mcp/catalog.js +88 -1
- package/package.json +1 -1
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
|
@@ -334,10 +334,20 @@ securenow ip traces <ip-address> --env production # traces originating from this
|
|
|
334
334
|
```bash
|
|
335
335
|
securenow forensics "show me all SQL injection attempts in the last 24h" --env production
|
|
336
336
|
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
|
|
337
|
+
securenow forensics chat --app my-app --env production # interactive forensics chat session (with memory)
|
|
338
|
+
securenow forensics chat --app my-app "which accounts had failed logins then a success?" # one question, prints the conversation id
|
|
339
|
+
securenow forensics chat --conversation <id> "what else did those IPs do?" # follow up: the chat remembers the earlier answers
|
|
340
|
+
securenow forensics conversations # list conversations (dashboard, CLI and agent ones)
|
|
341
|
+
securenow forensics conversations <id> # show one: questions, SQL, findings, rows
|
|
338
342
|
securenow forensics library # view saved/template queries
|
|
339
343
|
```
|
|
340
344
|
|
|
345
|
+
The chat runs discovery queries before answering, shows its SQL, and ends each answer with findings: what the result rows
|
|
346
|
+
show. A follow-up sees the earlier questions, their findings, and the IPs and trace IDs in their results. MCP:
|
|
347
|
+
`securenow_forensics_chat` (pass `conversationId` to continue), `securenow_forensics_conversations_list`,
|
|
348
|
+
`securenow_forensics_conversation_get`. `securenow forensics "<q>"` and `securenow_forensics_query` answer one question
|
|
349
|
+
with no memory.
|
|
350
|
+
|
|
341
351
|
---
|
|
342
352
|
|
|
343
353
|
### Firewall
|
package/cli/automation.js
CHANGED
|
@@ -82,7 +82,7 @@ async function list(args, flags) {
|
|
|
82
82
|
|
|
83
83
|
console.log('');
|
|
84
84
|
const rows = rules.map((rule) => [
|
|
85
|
-
ui.c.dim(
|
|
85
|
+
ui.c.dim(rule.id || rule._id || ''),
|
|
86
86
|
rule.name || '-',
|
|
87
87
|
ui.statusBadge(rule.status || 'active'),
|
|
88
88
|
formatApplications(rule),
|
package/cli/challenges.js
CHANGED
|
@@ -64,7 +64,7 @@ async function list(args, flags) {
|
|
|
64
64
|
|
|
65
65
|
console.log('');
|
|
66
66
|
const rows = rules.map((r) => [
|
|
67
|
-
ui.c.dim(
|
|
67
|
+
ui.c.dim(r.id || r._id || ''),
|
|
68
68
|
r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
|
|
69
69
|
describeTarget(r),
|
|
70
70
|
`pow:${r.difficulty}`,
|
package/cli/fp.js
CHANGED
|
@@ -76,7 +76,7 @@ async function list(args, flags) {
|
|
|
76
76
|
const rows = exclusions.map(e => {
|
|
77
77
|
const scope = e.type === 'global' ? ui.c.cyan('All Rules') : `Rule: ${ui.truncate(e.ruleName || '?', 18)}`;
|
|
78
78
|
return [
|
|
79
|
-
ui.c.dim(
|
|
79
|
+
ui.c.dim(e.id || e._id || ''),
|
|
80
80
|
scope,
|
|
81
81
|
ui.truncate(conditionsSummary(e.conditions, e.matchMode), 40),
|
|
82
82
|
(e.matchMode || 'all').toUpperCase(),
|
package/cli/monitor.js
CHANGED
|
@@ -351,7 +351,7 @@ async function notificationsList(args, flags) {
|
|
|
351
351
|
|
|
352
352
|
console.log('');
|
|
353
353
|
const rows = notifications.map(n => [
|
|
354
|
-
ui.c.dim(
|
|
354
|
+
ui.c.dim(n.id || n._id || ''),
|
|
355
355
|
ui.statusBadge(n.read ? 'read' : 'unread'),
|
|
356
356
|
ui.truncate(n.title || n.message || n.type || '', 50),
|
|
357
357
|
n.ip || '—',
|
package/cli/rateLimits.js
CHANGED
|
@@ -47,6 +47,16 @@ function describeTarget(rule) {
|
|
|
47
47
|
return parts.join(' ') || 'all traffic';
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// The bucket each request counts against: "ip", "global", "header x-api-key", "cookie session".
|
|
51
|
+
function describeKey(rule) {
|
|
52
|
+
const keyBy = rule.keyBy || 'ip';
|
|
53
|
+
return rule.keyName && (keyBy === 'header' || keyBy === 'cookie') ? `${keyBy} ${rule.keyName}` : keyBy;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isExpired(rule) {
|
|
57
|
+
return Boolean(rule.expiresAt) && new Date(rule.expiresAt).getTime() <= Date.now();
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
function naturalText(args, flags) {
|
|
51
61
|
return String(flags.text || flags.prompt || flags.query || args.join(' ') || '').trim();
|
|
52
62
|
}
|
|
@@ -62,6 +72,11 @@ function draftFromFlags(flags) {
|
|
|
62
72
|
if (flags.mode || flags['path-mode']) draft.pathMatchMode = flags.mode || flags['path-mode'];
|
|
63
73
|
if (flags.method) draft.method = flags.method;
|
|
64
74
|
if (flags['key-by'] || flags.keyBy) draft.keyBy = flags['key-by'] || flags.keyBy;
|
|
75
|
+
else if (flags.header) draft.keyBy = 'header';
|
|
76
|
+
else if (flags.cookie) draft.keyBy = 'cookie';
|
|
77
|
+
if (flags['key-name'] || flags.keyName || flags.header || flags.cookie) {
|
|
78
|
+
draft.keyName = flags['key-name'] || flags.keyName || flags.header || flags.cookie;
|
|
79
|
+
}
|
|
65
80
|
if (flags.limit) draft.limit = Number(flags.limit);
|
|
66
81
|
if (flags.window || flags['window-seconds']) draft.windowSeconds = parseWindow(flags.window || flags['window-seconds']);
|
|
67
82
|
if (flags.duration) draft.duration = flags.duration;
|
|
@@ -76,7 +91,7 @@ function printParsedDraft(data) {
|
|
|
76
91
|
['Name', draft.name || '-'],
|
|
77
92
|
['Target', describeTarget(draft)],
|
|
78
93
|
['Limit', `${draft.limit || '-'} / ${draft.windowSeconds || '-'}s`],
|
|
79
|
-
['Key by', draft
|
|
94
|
+
['Key by', describeKey(draft)],
|
|
80
95
|
['App', draft.appKey || 'all'],
|
|
81
96
|
['Environment', draft.environment || 'all'],
|
|
82
97
|
['Duration', draft.duration || 'none'],
|
|
@@ -107,16 +122,17 @@ async function list(args, flags) {
|
|
|
107
122
|
|
|
108
123
|
console.log('');
|
|
109
124
|
const rows = rules.map((r) => [
|
|
110
|
-
ui.c.dim(
|
|
111
|
-
r.status === 'active' ?
|
|
125
|
+
ui.c.dim(r.id || r._id || ''),
|
|
126
|
+
ui.statusBadge(r.status === 'active' && isExpired(r) ? 'expired' : (r.status || 'unknown')),
|
|
112
127
|
describeTarget(r),
|
|
113
128
|
`${r.limit}/${r.windowSeconds}s`,
|
|
129
|
+
describeKey(r),
|
|
114
130
|
r.applicationKey || ui.c.dim('all apps'),
|
|
115
131
|
r.environment || ui.c.dim('all envs'),
|
|
116
132
|
r.expiresAt ? new Date(r.expiresAt).toLocaleString() : ui.c.dim('permanent'),
|
|
117
133
|
ui.timeAgo(r.createdAt),
|
|
118
134
|
]);
|
|
119
|
-
ui.table(['ID', 'Status', 'Target', 'Limit', 'App', 'Env', 'Expires', 'Created'], rows);
|
|
135
|
+
ui.table(['ID', 'Status', 'Target', 'Limit', 'Per', 'App', 'Env', 'Expires', 'Created'], rows);
|
|
120
136
|
console.log('');
|
|
121
137
|
} catch (err) {
|
|
122
138
|
s.fail('Failed to fetch rate limits');
|
|
@@ -167,7 +183,7 @@ async function fromText(args, flags) {
|
|
|
167
183
|
if (flags.json) { ui.json(data); return; }
|
|
168
184
|
const r = data.rateLimit;
|
|
169
185
|
console.log('');
|
|
170
|
-
console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
|
|
186
|
+
console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
|
|
171
187
|
console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
|
|
172
188
|
console.log('');
|
|
173
189
|
} catch (err) {
|
|
@@ -197,7 +213,7 @@ async function show(args, flags) {
|
|
|
197
213
|
['Status', r.status || '-'],
|
|
198
214
|
['Target', describeTarget(r)],
|
|
199
215
|
['Limit', `${r.limit}/${r.windowSeconds}s`],
|
|
200
|
-
['Key by',
|
|
216
|
+
['Key by', describeKey(r)],
|
|
201
217
|
['App', r.applicationKey || 'all apps'],
|
|
202
218
|
['Environment', r.environment || 'all envs'],
|
|
203
219
|
['Reason', r.reason || '-'],
|
|
@@ -251,7 +267,7 @@ async function add(args, flags) {
|
|
|
251
267
|
if (flags.json) { ui.json(data); return; }
|
|
252
268
|
const r = data.rateLimit;
|
|
253
269
|
console.log('');
|
|
254
|
-
console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
|
|
270
|
+
console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
|
|
255
271
|
console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
|
|
256
272
|
console.log('');
|
|
257
273
|
} catch (err) {
|
|
@@ -330,7 +346,7 @@ async function test(args, flags) {
|
|
|
330
346
|
if (data.limited) {
|
|
331
347
|
console.log(` ${ui.c.bold(ui.c.yellow('RATE LIMITED'))} - ${data.matches.length} matching rule${data.matches.length !== 1 ? 's' : ''}`);
|
|
332
348
|
for (const r of data.matches.slice(0, 5)) {
|
|
333
|
-
console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s`);
|
|
349
|
+
console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
|
|
334
350
|
}
|
|
335
351
|
} else {
|
|
336
352
|
console.log(` ${ui.c.bold(ui.c.green('NOT LIMITED'))} - no matching rate-limit rules`);
|
package/cli/security.js
CHANGED
|
@@ -190,7 +190,7 @@ async function alertRulesList(args, flags) {
|
|
|
190
190
|
|
|
191
191
|
console.log('');
|
|
192
192
|
const rows = rules.map((r) => [
|
|
193
|
-
ui.c.dim(
|
|
193
|
+
ui.c.dim(r.id || r._id || ''),
|
|
194
194
|
r.name || '—',
|
|
195
195
|
ruleStatusBadge(r),
|
|
196
196
|
r.mode === 'test' ? ui.c.yellow('test') : ui.c.dim('prod'),
|
|
@@ -836,7 +836,7 @@ async function alertRuleExclusions(args, flags) {
|
|
|
836
836
|
if (flags.json) { ui.json(data); return; }
|
|
837
837
|
console.log('');
|
|
838
838
|
const rows = exclusions.map((e) => [
|
|
839
|
-
ui.c.dim(
|
|
839
|
+
ui.c.dim(e.id || e._id || ''),
|
|
840
840
|
e.isActive === false ? ui.statusBadge('disabled') : ui.statusBadge('active'),
|
|
841
841
|
e.matchMode || 'all',
|
|
842
842
|
ui.truncate(e.reason || e.pathPattern || JSON.stringify(e.conditions || []), 72),
|
|
@@ -862,7 +862,7 @@ async function alertChannelsList(args, flags) {
|
|
|
862
862
|
|
|
863
863
|
console.log('');
|
|
864
864
|
const rows = channels.map(ch => [
|
|
865
|
-
ui.c.dim(
|
|
865
|
+
ui.c.dim(ch.id || ch._id || ''),
|
|
866
866
|
ch.name || '—',
|
|
867
867
|
ch.type || '—',
|
|
868
868
|
ui.statusBadge(ch.enabled !== false ? 'enabled' : 'disabled'),
|
|
@@ -891,7 +891,7 @@ async function alertHistoryList(args, flags) {
|
|
|
891
891
|
|
|
892
892
|
console.log('');
|
|
893
893
|
const rows = history.map(h => [
|
|
894
|
-
ui.c.dim(
|
|
894
|
+
ui.c.dim(h.id || h._id || ''),
|
|
895
895
|
h.ruleName || h.type || '—',
|
|
896
896
|
h.severity ? ui.statusBadge(h.severity) : '—',
|
|
897
897
|
ui.truncate(h.message || h.description || '', 40),
|
|
@@ -938,7 +938,7 @@ async function blocklistList(args, flags) {
|
|
|
938
938
|
|
|
939
939
|
console.log('');
|
|
940
940
|
const rows = items.map(b => [
|
|
941
|
-
ui.c.dim(
|
|
941
|
+
ui.c.dim(b.id || b._id || ''),
|
|
942
942
|
ui.c.red(describeBlockTarget(b)),
|
|
943
943
|
ui.truncate(b.reason || '', 40),
|
|
944
944
|
b.source || '—',
|
|
@@ -1011,7 +1011,7 @@ async function revokeList(args, flags) {
|
|
|
1011
1011
|
if (flags.json) { ui.json(entries); return; }
|
|
1012
1012
|
console.log('');
|
|
1013
1013
|
ui.table(['ID', 'Type', 'Value', 'Scope', 'Expires'], entries.map((r) => [
|
|
1014
|
-
ui.c.dim(
|
|
1014
|
+
ui.c.dim(r.id || r._id || ''),
|
|
1015
1015
|
r.type,
|
|
1016
1016
|
ui.truncate(r.value, 28),
|
|
1017
1017
|
r.applicationKey || ui.c.dim('all'),
|
|
@@ -1191,7 +1191,7 @@ async function allowlistList(args, flags) {
|
|
|
1191
1191
|
|
|
1192
1192
|
console.log('');
|
|
1193
1193
|
const rows = items.map(a => [
|
|
1194
|
-
ui.c.dim(
|
|
1194
|
+
ui.c.dim(a.id || a._id || ''),
|
|
1195
1195
|
ui.c.green(a.ip || '—'),
|
|
1196
1196
|
a.label || ui.c.dim('—'),
|
|
1197
1197
|
ui.truncate(a.reason || '', 40),
|
|
@@ -1307,7 +1307,7 @@ async function trustedList(args, flags) {
|
|
|
1307
1307
|
|
|
1308
1308
|
console.log('');
|
|
1309
1309
|
const rows = items.map(t => [
|
|
1310
|
-
ui.c.dim(
|
|
1310
|
+
ui.c.dim(t.id || t._id || ''),
|
|
1311
1311
|
ui.c.green(t.ip || t.cidr || '—'),
|
|
1312
1312
|
t.label || t.description || ui.c.dim('—'),
|
|
1313
1313
|
t.applicationKey || ui.c.dim('all apps'),
|
|
@@ -1494,7 +1494,7 @@ async function forensicsLibrary(args, flags) {
|
|
|
1494
1494
|
|
|
1495
1495
|
console.log('');
|
|
1496
1496
|
const rows = queries.map(q => [
|
|
1497
|
-
ui.c.dim(
|
|
1497
|
+
ui.c.dim(q.id || q._id || ''),
|
|
1498
1498
|
q.name || q.title || '—',
|
|
1499
1499
|
ui.truncate(q.description || q.query || '', 50),
|
|
1500
1500
|
ui.timeAgo(q.createdAt),
|
|
@@ -1509,24 +1509,124 @@ async function forensicsLibrary(args, flags) {
|
|
|
1509
1509
|
|
|
1510
1510
|
// ── Forensics Chat ──
|
|
1511
1511
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1512
|
+
const CHAT_PRINT_ROWS = 25;
|
|
1513
|
+
|
|
1514
|
+
// Log timestamps come back as nanosecond (or microsecond) epochs when a query returns
|
|
1515
|
+
// min(timestamp) and the like from the logs table; print them as times.
|
|
1516
|
+
function chatCell(value) {
|
|
1517
|
+
const s = String(value ?? '');
|
|
1518
|
+
if (/^\d{16,19}$/.test(s)) {
|
|
1519
|
+
const n = Number(s);
|
|
1520
|
+
const ms = n >= 1e18 && n < 4e18 ? n / 1e6 : n >= 1e15 && n < 4e15 ? n / 1e3 : null;
|
|
1521
|
+
if (ms != null) return new Date(ms).toISOString().replace('T', ' ').replace('Z', '');
|
|
1522
|
+
}
|
|
1523
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : s;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// The API answers a chat message with 202 and works in the background; poll until
|
|
1527
|
+
// the reply settles (complete or awaiting_confirmation) or about five minutes pass.
|
|
1528
|
+
async function waitForChatReply(conversationId, environment, s) {
|
|
1529
|
+
for (let i = 0; i < 150; i++) {
|
|
1530
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1531
|
+
const result = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
|
|
1532
|
+
if (result.status !== 'processing') return { ...result, conversationId };
|
|
1533
|
+
if (result._progress?.action && s) s.update(result._progress.action);
|
|
1534
|
+
}
|
|
1535
|
+
return { conversationId, status: 'processing' };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function sendChatMessage(body, environment, s) {
|
|
1539
|
+
const chatRes = await api.post('/forensics/chat', body);
|
|
1540
|
+
if (chatRes.status !== 'processing') {
|
|
1541
|
+
return { conversationId: chatRes.conversationId, status: 'complete', message: chatRes.message };
|
|
1542
|
+
}
|
|
1543
|
+
return waitForChatReply(chatRes.conversationId, environment, s);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
async function confirmChatQuery(conversationId, environment, s) {
|
|
1547
|
+
await api.post(`/forensics/chat/confirm/${conversationId}`, { environment });
|
|
1548
|
+
return waitForChatReply(conversationId, environment, s);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Resolve what a chat talks to: an existing conversation (--conversation <id>,
|
|
1552
|
+
// which carries its own app and environment) or a new one scoped to --app.
|
|
1553
|
+
async function resolveChatTarget(flags) {
|
|
1554
|
+
if (typeof flags.conversation === 'string' && flags.conversation) {
|
|
1555
|
+
const data = await api.get(`/forensics/conversations/${flags.conversation}`);
|
|
1556
|
+
const conv = data.conversation;
|
|
1557
|
+
return {
|
|
1558
|
+
conversationId: conv.id,
|
|
1559
|
+
title: conv.title,
|
|
1560
|
+
label: conv.applicationName || conv.applicationKey || conv.applicationId,
|
|
1561
|
+
appKey: conv.applicationKey,
|
|
1562
|
+
environment: conv.environment || 'production',
|
|
1563
|
+
messageCount: (conv.messages || []).length,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1514
1566
|
const resolved = await resolveAppId(flags);
|
|
1515
1567
|
if (!resolved) {
|
|
1516
|
-
ui.error('App required. Usage: securenow forensics chat --app <key>');
|
|
1568
|
+
ui.error('App required. Usage: securenow forensics chat --app <key>, or --conversation <id> to continue one.');
|
|
1517
1569
|
ui.info('Set a default with: securenow apps default <key>');
|
|
1518
1570
|
process.exit(1);
|
|
1519
1571
|
}
|
|
1572
|
+
return {
|
|
1573
|
+
conversationId: null,
|
|
1574
|
+
label: resolved.name || resolved.key,
|
|
1575
|
+
appKey: resolved.key,
|
|
1576
|
+
environment: resolveEnvironment(flags, 'production'),
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1520
1579
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1580
|
+
async function forensicsChat(args, flags) {
|
|
1581
|
+
requireAuth();
|
|
1582
|
+
// `--json "<question>"` parses the question as the flag's value; give it back.
|
|
1583
|
+
const words = [...args];
|
|
1584
|
+
for (const key of ['json', 'yes', 'force']) {
|
|
1585
|
+
if (typeof flags[key] === 'string') { words.push(flags[key]); flags[key] = true; }
|
|
1586
|
+
}
|
|
1587
|
+
const target = await resolveChatTarget(flags);
|
|
1588
|
+
const { environment } = target;
|
|
1589
|
+
let conversationId = target.conversationId;
|
|
1590
|
+
const question = words.join(' ').trim();
|
|
1591
|
+
|
|
1592
|
+
// One question, one reply, then exit: scriptable, and what an agent without MCP uses.
|
|
1593
|
+
if (question) {
|
|
1594
|
+
const s = flags.json ? null : ui.spinner('Thinking');
|
|
1595
|
+
const body = { message: question, environment };
|
|
1596
|
+
if (conversationId) body.conversationId = conversationId;
|
|
1597
|
+
else body.applicationKey = target.appKey;
|
|
1598
|
+
let reply = await sendChatMessage(body, environment, s);
|
|
1599
|
+
if (reply.status === 'awaiting_confirmation' && (flags.yes || flags.force)) {
|
|
1600
|
+
if (s) s.update('Running the large query');
|
|
1601
|
+
reply = await confirmChatQuery(reply.conversationId, environment, s);
|
|
1602
|
+
}
|
|
1603
|
+
if (flags.json) { ui.json(reply); return; }
|
|
1604
|
+
if (reply.status === 'processing') {
|
|
1605
|
+
s.fail('Still working');
|
|
1606
|
+
ui.info(`See the reply later: securenow forensics conversations ${reply.conversationId}`);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
s.stop('Done');
|
|
1610
|
+
printChatMessage(reply.message);
|
|
1611
|
+
console.log('');
|
|
1612
|
+
if (reply.status === 'awaiting_confirmation') {
|
|
1613
|
+
ui.info('Large query held back. Add --yes to run it, or ask a narrower question.');
|
|
1614
|
+
}
|
|
1615
|
+
ui.info(`Conversation ${reply.conversationId}`);
|
|
1616
|
+
console.log(ui.c.dim(` Follow up: securenow forensics chat --conversation ${reply.conversationId} "<question>"`));
|
|
1617
|
+
console.log('');
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1525
1620
|
|
|
1526
|
-
|
|
1621
|
+
console.log('');
|
|
1622
|
+
ui.heading(`Forensics Chat — ${target.label}`);
|
|
1623
|
+
if (conversationId) {
|
|
1624
|
+
console.log(ui.c.dim(` Continuing: ${target.title} (${target.messageCount} messages)`));
|
|
1625
|
+
} else {
|
|
1626
|
+
console.log(ui.c.dim(` App: ${target.appKey}`));
|
|
1627
|
+
}
|
|
1527
1628
|
console.log(ui.c.dim(` Env: ${environment}`));
|
|
1528
|
-
|
|
1529
|
-
let conversationId = null;
|
|
1629
|
+
console.log(ui.c.dim(' Type your question, or "exit" to quit.\n'));
|
|
1530
1630
|
|
|
1531
1631
|
const readline = require('readline');
|
|
1532
1632
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
@@ -1541,57 +1641,37 @@ async function forensicsChat(args, flags) {
|
|
|
1541
1641
|
if (!message || message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
|
|
1542
1642
|
console.log('');
|
|
1543
1643
|
ui.info('Chat ended.');
|
|
1644
|
+
if (conversationId) console.log(ui.c.dim(` Continue later: securenow forensics chat --conversation ${conversationId}`));
|
|
1544
1645
|
break;
|
|
1545
1646
|
}
|
|
1546
1647
|
|
|
1547
1648
|
const s = ui.spinner('Thinking');
|
|
1548
1649
|
try {
|
|
1549
|
-
const body = { message,
|
|
1650
|
+
const body = { message, environment };
|
|
1550
1651
|
if (conversationId) body.conversationId = conversationId;
|
|
1652
|
+
else body.applicationKey = target.appKey;
|
|
1551
1653
|
|
|
1552
|
-
const
|
|
1553
|
-
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
|
-
}
|
|
1654
|
+
const reply = await sendChatMessage(body, environment, s);
|
|
1655
|
+
conversationId = reply.conversationId;
|
|
1564
1656
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1657
|
+
if (reply.status === 'processing') {
|
|
1658
|
+
s.fail('Response timed out');
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1569
1661
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
}
|
|
1662
|
+
s.stop('Done');
|
|
1663
|
+
printChatMessage(reply.message);
|
|
1664
|
+
|
|
1665
|
+
if (reply.status === 'awaiting_confirmation') {
|
|
1666
|
+
const proceed = await ui.confirm('Proceed with this query?');
|
|
1667
|
+
if (proceed) {
|
|
1668
|
+
const cs = ui.spinner('Executing query');
|
|
1669
|
+
const confirmed = await confirmChatQuery(conversationId, environment, cs);
|
|
1670
|
+
cs.stop('Done');
|
|
1671
|
+
if (confirmed?.message) printChatMessage(confirmed.message);
|
|
1672
|
+
} else {
|
|
1673
|
+
ui.info('Query skipped. Ask a different question or refine.');
|
|
1589
1674
|
}
|
|
1590
|
-
} else if (chatRes.message) {
|
|
1591
|
-
s.stop('Done');
|
|
1592
|
-
printChatMessage(chatRes.message);
|
|
1593
|
-
} else {
|
|
1594
|
-
s.stop('Done');
|
|
1595
1675
|
}
|
|
1596
1676
|
} catch (err) {
|
|
1597
1677
|
s.fail('Error');
|
|
@@ -1604,9 +1684,61 @@ async function forensicsChat(args, flags) {
|
|
|
1604
1684
|
}
|
|
1605
1685
|
}
|
|
1606
1686
|
|
|
1607
|
-
function
|
|
1687
|
+
async function forensicsConversations(args, flags) {
|
|
1688
|
+
requireAuth();
|
|
1689
|
+
const id = args[0];
|
|
1690
|
+
|
|
1691
|
+
if (id) {
|
|
1692
|
+
const s = flags.json ? null : ui.spinner('Fetching conversation');
|
|
1693
|
+
const data = await api.get(`/forensics/conversations/${id}`);
|
|
1694
|
+
const conv = data.conversation;
|
|
1695
|
+
if (flags.json) { ui.json(conv); return; }
|
|
1696
|
+
s.stop(conv.title);
|
|
1697
|
+
console.log(ui.c.dim(` ${conv.applicationName || conv.applicationKey} · ${conv.environment} · ${(conv.messages || []).length} messages · ${conv.id}`));
|
|
1698
|
+
for (const msg of conv.messages || []) {
|
|
1699
|
+
if (msg.role === 'user') {
|
|
1700
|
+
console.log(`\n ${ui.c.cyan('you >')} ${msg.content}`);
|
|
1701
|
+
} else {
|
|
1702
|
+
printChatMessage(msg, { maxRows: 10 });
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
console.log('');
|
|
1706
|
+
console.log(ui.c.dim(` Continue: securenow forensics chat --conversation ${conv.id} "<question>"`));
|
|
1707
|
+
console.log('');
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const s = flags.json ? null : ui.spinner('Fetching conversations');
|
|
1712
|
+
const query = {};
|
|
1713
|
+
if (flags.app) {
|
|
1714
|
+
const resolved = await resolveAppId(flags);
|
|
1715
|
+
if (resolved) query.applicationId = resolved.id;
|
|
1716
|
+
}
|
|
1717
|
+
const data = await api.get('/forensics/conversations', { query });
|
|
1718
|
+
const list = data.conversations || [];
|
|
1719
|
+
if (flags.json) { ui.json(list); return; }
|
|
1720
|
+
s.stop(`Found ${list.length} conversation${list.length !== 1 ? 's' : ''}`);
|
|
1721
|
+
console.log('');
|
|
1722
|
+
ui.table(['ID', 'Title', 'App', 'Env', 'Msgs', 'Updated'], list.map(c => [
|
|
1723
|
+
ui.c.dim(c.id),
|
|
1724
|
+
ui.truncate(c.title || '', 48),
|
|
1725
|
+
c.applicationName || '—',
|
|
1726
|
+
c.environment || '—',
|
|
1727
|
+
String(c.messageCount ?? 0),
|
|
1728
|
+
ui.timeAgo(c.updatedAt),
|
|
1729
|
+
]));
|
|
1730
|
+
console.log('');
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function printChatMessage(msg, { maxRows = CHAT_PRINT_ROWS } = {}) {
|
|
1608
1734
|
if (!msg) return;
|
|
1609
1735
|
console.log('');
|
|
1736
|
+
if (msg.reasoning) {
|
|
1737
|
+
console.log(ui.c.dim(` plan: ${msg.reasoning}`));
|
|
1738
|
+
}
|
|
1739
|
+
if (Array.isArray(msg.discoverySteps) && msg.discoverySteps.length > 0) {
|
|
1740
|
+
console.log(ui.c.dim(` ${msg.discoverySteps.length} discovery quer${msg.discoverySteps.length !== 1 ? 'ies' : 'y'} run first`));
|
|
1741
|
+
}
|
|
1610
1742
|
if (msg.content) {
|
|
1611
1743
|
console.log(` ${ui.c.green('ai >')} ${msg.content}`);
|
|
1612
1744
|
}
|
|
@@ -1616,11 +1748,18 @@ function printChatMessage(msg) {
|
|
|
1616
1748
|
console.log(`\n ${ui.c.dim(msg.sqlQuery)}\n`);
|
|
1617
1749
|
}
|
|
1618
1750
|
if (msg.results && Array.isArray(msg.results) && msg.results.length > 0) {
|
|
1619
|
-
|
|
1751
|
+
const total = msg.resultCount ?? msg.results.length;
|
|
1752
|
+
ui.subheading(`Results (${total} rows)`);
|
|
1620
1753
|
console.log('');
|
|
1621
1754
|
const headers = Object.keys(msg.results[0]);
|
|
1622
|
-
const rows = msg.results.map(row => headers.map(h =>
|
|
1755
|
+
const rows = msg.results.slice(0, maxRows).map(row => headers.map(h => chatCell(row[h])));
|
|
1623
1756
|
ui.table(headers, rows);
|
|
1757
|
+
if (msg.results.length > maxRows) {
|
|
1758
|
+
console.log(ui.c.dim(` … ${msg.results.length - maxRows} more rows (--json for all)`));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (msg.findings) {
|
|
1762
|
+
console.log(`\n ${ui.c.cyan('findings >')} ${msg.findings}`);
|
|
1624
1763
|
}
|
|
1625
1764
|
if (msg.estimation) {
|
|
1626
1765
|
const est = msg.estimation;
|
|
@@ -1756,7 +1895,7 @@ async function instancesList(args, flags) {
|
|
|
1756
1895
|
|
|
1757
1896
|
console.log('');
|
|
1758
1897
|
const rows = instances.map(inst => [
|
|
1759
|
-
ui.c.dim(
|
|
1898
|
+
ui.c.dim(inst.id || inst._id || ''),
|
|
1760
1899
|
inst.name || inst.host || '—',
|
|
1761
1900
|
inst.host || '—',
|
|
1762
1901
|
inst.port != null ? String(inst.port) : '—',
|
|
@@ -1857,6 +1996,7 @@ module.exports = {
|
|
|
1857
1996
|
trustedRemove,
|
|
1858
1997
|
forensicsQuery,
|
|
1859
1998
|
forensicsChat,
|
|
1999
|
+
forensicsConversations,
|
|
1860
2000
|
forensicsLibrary,
|
|
1861
2001
|
ipLookup,
|
|
1862
2002
|
ipTraces,
|
package/cli/ui.js
CHANGED
|
@@ -354,6 +354,7 @@ function statusBadge(status) {
|
|
|
354
354
|
trusted: c.green('■ trusted'),
|
|
355
355
|
enabled: c.green('● enabled'),
|
|
356
356
|
disabled: c.dim('○ disabled'),
|
|
357
|
+
expired: c.dim('○ expired'),
|
|
357
358
|
paused: c.yellow('◆ paused'),
|
|
358
359
|
critical: c.red('▲ critical'),
|
|
359
360
|
high: c.red('▲ high'),
|
package/cli.js
CHANGED
|
@@ -585,7 +585,8 @@ const COMMANDS = {
|
|
|
585
585
|
usage: 'securenow forensics <query> [--app <key>]',
|
|
586
586
|
sub: {
|
|
587
587
|
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: '
|
|
588
|
+
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) },
|
|
589
|
+
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
590
|
library: { desc: 'View saved queries', run: (a, f) => require('./cli/security').forensicsLibrary(a, f) },
|
|
590
591
|
},
|
|
591
592
|
defaultAction: (a, f) => require('./cli/security').forensicsQuery(a, f),
|
package/firewall.js
CHANGED
|
@@ -1478,7 +1478,7 @@ function init(options) {
|
|
|
1478
1478
|
_rateLimitStore = require('./rate-limit-store').createRateLimitStore(storeSpec, {
|
|
1479
1479
|
log: (...args) => { if (_options.log) fwLog(...args); },
|
|
1480
1480
|
});
|
|
1481
|
-
if (_options.log && _rateLimitStore) fwLog('[securenow] Firewall: shared rate-limit counters
|
|
1481
|
+
if (_options.log && _rateLimitStore) fwLog('[securenow] Firewall: shared rate-limit counters: connecting to %s', _rateLimitStore.describe());
|
|
1482
1482
|
} catch (e) {
|
|
1483
1483
|
_rateLimitStore = null;
|
|
1484
1484
|
if (_options.log) fwWarn('[securenow] Firewall: rate-limit store disabled (%s); counting in-process', e.message);
|
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
|
|
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',
|
package/package.json
CHANGED