securenow 8.15.0 → 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/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
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];
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',
package/mcp/catalog.js CHANGED
@@ -1378,6 +1378,70 @@ const TOOLS = [
1378
1378
  ...confirmSchema,
1379
1379
  }, ['text', 'confirm', 'reason']),
1380
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
+ },
1381
1445
  {
1382
1446
  name: 'securenow_alert_rules_list',
1383
1447
  title: 'List Alert Rules',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.15.0",
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",