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 +5 -0
- package/README.md +2 -0
- package/SKILL-CLI.md +16 -1
- package/cli/monitor.js +3 -1
- package/cli/security.js +373 -60
- package/cli.js +13 -5
- package/mcp/catalog.js +152 -1
- package/package.json +1 -1
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)
|
|
129
|
-
//
|
|
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
|
-
|
|
225
|
-
|
|
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 (
|
|
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
|
-
|
|
1513
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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,
|
|
1823
|
+
const body = { message, environment };
|
|
1550
1824
|
if (conversationId) body.conversationId = conversationId;
|
|
1825
|
+
else body.applicationKey = target.appKey;
|
|
1551
1826
|
|
|
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
|
-
}
|
|
1827
|
+
const reply = await sendChatMessage(body, environment, s);
|
|
1828
|
+
conversationId = reply.conversationId;
|
|
1564
1829
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1830
|
+
if (reply.status === 'processing') {
|
|
1831
|
+
s.fail('Response timed out');
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1569
1834
|
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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