securenow 8.4.0 → 8.6.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/cli/security.js CHANGED
@@ -45,10 +45,16 @@ async function alertRulesRoute(args, flags) {
45
45
  if (sub === 'update') {
46
46
  return alertRuleUpdate(args.slice(1), flags);
47
47
  }
48
+ if (sub === 'set-sql' || sub === 'edit-sql') {
49
+ return alertRuleSetSql(args.slice(1), flags);
50
+ }
51
+ if (sub === 'delete' || sub === 'rm' || sub === 'remove') {
52
+ return alertRuleDelete(args.slice(1), flags);
53
+ }
48
54
  if (sub === 'test') {
49
55
  return alertRuleTest(args.slice(1), flags);
50
56
  }
51
- if (sub === 'dry-run-query' || sub === 'candidate-test') {
57
+ if (sub === 'dry-run-query' || sub === 'candidate-test' || sub === 'validate') {
52
58
  return alertRuleCandidateTest(args.slice(1), flags);
53
59
  }
54
60
  if (sub === 'tune-query' || sub === 'query-update') {
@@ -156,14 +162,35 @@ async function alertRuleCreate(args, flags) {
156
162
  }
157
163
 
158
164
  const s = ui.spinner('Creating alert rule');
165
+ let createdId = null;
159
166
  try {
160
167
  const data = await api.post('/alert-rules', body);
161
168
  const r = data.alertRule || data;
169
+ createdId = r._id || r.id || null;
162
170
  s.stop('Alert rule created');
171
+
172
+ // Transactional safety: `create` only runs scope/safety validation, not a real
173
+ // query execution — so a query that is valid-but-wrong (e.g. an unknown column)
174
+ // is accepted and would silently never fire. Custom-rule SQL also can't be
175
+ // patched via the API, so a broken rule would be stuck. We therefore dry-run the
176
+ // new scheduled rule and roll it back (delete) on a hard query failure, unless
177
+ // --no-validate is passed. A "complete" dry-run with 0 rows is healthy, not a failure.
178
+ const scheduled = r.executionMode !== 'instant' && r.schedule?.enabled !== false;
179
+ if (createdId && scheduled && !flags['no-validate']) {
180
+ const v = await validateRuleById(createdId, flags);
181
+ if (v && v.status === 'failed') {
182
+ const rolledBack = await api.delete(`/alert-rules/${createdId}`).then(() => true).catch(() => false);
183
+ ui.error(`Rule query failed validation and was ${rolledBack ? 'rolled back (deleted)' : 'left in place (auto-delete failed — remove it manually)'}:`);
184
+ ui.error(` ${v.error || 'unknown query error'}`);
185
+ ui.info('Fix the SQL and re-create, or validate first: securenow alerts rules validate --sql @rule.sql --app <key>');
186
+ process.exit(1);
187
+ }
188
+ }
189
+
163
190
  if (flags.json) { ui.json(data); return; }
164
191
  console.log('');
165
192
  ui.keyValue([
166
- ['ID', r._id || r.id || '-'],
193
+ ['ID', createdId || '-'],
167
194
  ['Name', r.name || '-'],
168
195
  ['Status', r.status || '-'],
169
196
  ['Mode', r.executionMode || 'scheduled'],
@@ -172,9 +199,10 @@ async function alertRuleCreate(args, flags) {
172
199
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '-')],
173
200
  ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
174
201
  ['Query', r.queryMappingId?.name || r.queryMappingId || '-'],
202
+ ['Validation', flags['no-validate'] ? 'skipped' : 'dry-run clean'],
175
203
  ]);
176
204
  console.log('');
177
- ui.success(`Created. View it with: securenow alerts rules show ${r._id || r.id}`);
205
+ ui.success(`Created. View it with: securenow alerts rules show ${createdId}`);
178
206
  } catch (err) {
179
207
  s.fail('Failed to create alert rule');
180
208
  throw err;
@@ -223,6 +251,11 @@ async function alertRuleShow(args, flags) {
223
251
 
224
252
  async function alertRuleUpdate(args, flags) {
225
253
  requireAuth();
254
+ // `update <id> --sql ...` is an alias for set-sql so SQL edits and scope edits
255
+ // share one verb. set-sql handles its own validation/feedback.
256
+ if (flags.sql || flags.query || flags.file) {
257
+ return alertRuleSetSql(args, flags);
258
+ }
226
259
  const id = args[0];
227
260
  if (!id) {
228
261
  ui.error('Usage: securenow alerts rules update <rule-id> (--applications-all | --apps <k1,k2>)');
@@ -280,6 +313,88 @@ async function alertRuleUpdate(args, flags) {
280
313
  }
281
314
  }
282
315
 
316
+ async function alertRuleSetSql(args, flags) {
317
+ requireAuth();
318
+ const id = args[0];
319
+ const sqlQuery = readSqlArg(flags);
320
+ if (!id || !sqlQuery) {
321
+ ui.error('Usage: securenow alerts rules set-sql <rule-id> --sql <sql|@file|-> [--nlp "intent"] [--no-validate]');
322
+ process.exit(1);
323
+ }
324
+ const body = { sqlQuery };
325
+ if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
326
+ if (flags.category) body.category = flags.category;
327
+
328
+ const s = ui.spinner('Updating rule SQL');
329
+ try {
330
+ const data = await api.put(`/alert-rules/${id}`, body);
331
+ s.stop('Rule SQL updated');
332
+ const ok = flags['no-validate'] ? null : await validateRuleById(id, flags);
333
+ if (ok && ok.status === 'failed') {
334
+ ui.warn(`Saved, but the new SQL failed a dry-run: ${ok.error || 'unknown error'}`);
335
+ ui.info('Fix the SQL and run set-sql again, or delete the rule with: securenow alerts rules delete ' + id);
336
+ } else if (ok) {
337
+ ui.success('Saved and dry-run clean.');
338
+ }
339
+ if (flags.json) ui.json(data);
340
+ } catch (err) {
341
+ s.fail('Failed to update rule SQL');
342
+ throw err;
343
+ }
344
+ }
345
+
346
+ async function alertRuleDelete(args, flags) {
347
+ requireAuth();
348
+ const id = args[0];
349
+ if (!id) {
350
+ ui.error('Usage: securenow alerts rules delete <rule-id> [--yes]');
351
+ process.exit(1);
352
+ }
353
+ if (!flags.force && !flags.yes) {
354
+ const ok = await ui.confirm(`Delete alert rule ${id}? This cannot be undone (system rules can only be disabled).`);
355
+ if (!ok) { ui.info('Cancelled'); return; }
356
+ }
357
+ const s = ui.spinner('Deleting alert rule');
358
+ try {
359
+ const data = await api.delete(`/alert-rules/${id}`);
360
+ s.stop('Alert rule deleted');
361
+ if (flags.json) { ui.json(data); return; }
362
+ ui.success(`Deleted alert rule ${id}`);
363
+ } catch (err) {
364
+ s.fail('Failed to delete alert rule');
365
+ throw err;
366
+ }
367
+ }
368
+
369
+ // Run a dry-run test of an existing rule and return { status, error, resultCount }.
370
+ // Used to validate a rule after create/set-sql. Never throws — returns null on error.
371
+ async function validateRuleById(id, flags = {}) {
372
+ try {
373
+ let data = await api.post(`/alert-rules/${id}/test`, { mode: 'dry_run' });
374
+ if (data.testId) {
375
+ for (let i = 0; i < 40; i++) {
376
+ await new Promise((resolve) => setTimeout(resolve, 2000));
377
+ data = await api.get(`/alert-rules/${id}/test/${data.testId}`);
378
+ if (['complete', 'failed'].includes(data.status)) break;
379
+ }
380
+ }
381
+ return { status: data.status, error: data.error, resultCount: data.resultCount };
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ // Pick an existing rule id to host a candidate dry-run (the test endpoint needs a
388
+ // rule to attach the candidate SQL to). Prefer a system rule (always present on
389
+ // provisioned accounts and never mutated by a dry-run).
390
+ async function resolveHostRuleId() {
391
+ const data = await api.get('/alert-rules');
392
+ const rules = data.alertRules || data.rules || [];
393
+ if (!rules.length) return null;
394
+ const sys = rules.find((r) => r.isSystem);
395
+ return (sys || rules[0])._id || (sys || rules[0]).id || null;
396
+ }
397
+
283
398
  function readSqlArg(flags) {
284
399
  const raw = flags.sql || flags.query || flags.file;
285
400
  if (!raw) return null;
@@ -331,12 +446,24 @@ async function alertRuleTest(args, flags) {
331
446
 
332
447
  async function alertRuleCandidateTest(args, flags) {
333
448
  requireAuth();
334
- const id = args[0];
449
+ let id = args[0];
335
450
  const candidateSqlQuery = readSqlArg(flags);
336
- if (!id || !candidateSqlQuery) {
337
- ui.error('Usage: securenow alerts rules dry-run-query <rule-id> --sql <sql|@file|-> [--app <key>] [--wait]');
451
+ if (!candidateSqlQuery) {
452
+ ui.error('Usage: securenow alerts rules validate --sql <sql|@file|-> [--app <key>] (or dry-run-query <rule-id> --sql ...)');
338
453
  process.exit(1);
339
454
  }
455
+ // The test endpoint attaches the candidate SQL to an existing rule. When no
456
+ // rule id is given, auto-pick a host so a candidate can be validated before any
457
+ // rule exists for it (every provisioned account has system rules to host on).
458
+ if (!id) {
459
+ id = await resolveHostRuleId();
460
+ if (!id) {
461
+ ui.error('No existing rule to host the dry-run. Create one rule first, or pass <rule-id>.');
462
+ process.exit(1);
463
+ }
464
+ }
465
+ // `validate` is meant to return a verdict, so wait for the result by default.
466
+ const wait = flags.wait || flags['no-wait'] !== true;
340
467
 
341
468
  const body = { mode: 'dry_run', candidateSqlQuery };
342
469
  if (flags.app) body.applicationKey = flags.app;
@@ -344,7 +471,7 @@ async function alertRuleCandidateTest(args, flags) {
344
471
  const s = ui.spinner('Starting candidate SQL dry-run');
345
472
  try {
346
473
  let data = await api.post(`/alert-rules/${id}/test`, body);
347
- if (flags.wait && data.testId) {
474
+ if (wait && data.testId) {
348
475
  s.update('Waiting for candidate SQL dry-run results');
349
476
  for (let i = 0; i < 40; i++) {
350
477
  await new Promise((resolve) => setTimeout(resolve, 2000));
package/cli.js CHANGED
@@ -255,7 +255,8 @@ const COMMANDS = {
255
255
  usage: 'securenow alerts <subcommand> [options]',
256
256
  sub: {
257
257
  rules: {
258
- desc: 'Create, list, show, update, test, or tune alert rules',
258
+ desc: 'Create, list, show, update, set-sql, validate, delete, test, or tune alert rules',
259
+ usage: 'securenow alerts rules <list|create|show|update|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions> [options]',
259
260
  flags: {
260
261
  json: 'Output as JSON',
261
262
  name: 'With create: rule name',
@@ -276,9 +277,10 @@ const COMMANDS = {
276
277
  app: 'Application key for rule tests',
277
278
  mode: 'Rule test mode: dry_run or live',
278
279
  wait: 'Wait for rule test completion',
279
- sql: 'Detection/candidate/replacement SQL, @file, or - for stdin',
280
+ sql: 'Detection/candidate/replacement SQL, @file, or - for stdin (create, set-sql, update, validate, dry-run-query, tune-query)',
280
281
  query: 'Alias for --sql',
281
282
  file: 'Read SQL from a file',
283
+ 'no-validate': 'With create/set-sql: skip the post-write dry-run + rollback',
282
284
  reason: 'Audit reason for a write',
283
285
  'apply-globally': 'Required for system query tuning',
284
286
  'reactivate-paused': 'Reactivate paused system copies after tuning',
@@ -387,6 +389,37 @@ const COMMANDS = {
387
389
  },
388
390
  defaultSub: 'list',
389
391
  },
392
+ challenge: {
393
+ desc: 'Manage CAPTCHA / proof-of-work challenge remediation rules',
394
+ usage: 'securenow challenge <list|add|show|test|enable|disable|remove> [options]',
395
+ flags: {
396
+ app: 'Scope to app key (defaults to logged-in app)',
397
+ env: 'Scope to environment (default for create/test: production)',
398
+ environment: 'Alias for --env',
399
+ json: 'Output as JSON',
400
+ difficulty: 'Proof-of-work strength in leading zero bits (4-28, default 14)',
401
+ clearance: 'How long a solve clears, e.g. 30m, 1h (default 30m)',
402
+ route: 'Path pattern such as /login',
403
+ path: 'Alias for --route',
404
+ mode: 'Path mode: exact, prefix, or regex',
405
+ method: 'HTTP method, or ALL',
406
+ duration: 'Rule expiry, e.g. 24h or 7d',
407
+ reason: 'Reason note',
408
+ 'escalate-to-block': 'Promote to a hard block after repeated failures',
409
+ 'fail-threshold': 'Failures before escalation (default 10)',
410
+ 'block-ttl-hours': 'Block duration when escalating (default 24)',
411
+ },
412
+ sub: {
413
+ list: { desc: 'List challenge remediation rules', run: (a, f) => require('./cli/challenges').list(a, f) },
414
+ add: { desc: 'Create a challenge rule', usage: 'securenow challenge add [ip] --route /login --difficulty 16 --clearance 30m', run: (a, f) => require('./cli/challenges').add(a, f) },
415
+ show: { desc: 'Show one challenge rule', usage: 'securenow challenge show <id>', run: (a, f) => require('./cli/challenges').show(a, f) },
416
+ test: { desc: 'Check whether a request would be challenged', usage: 'securenow challenge test <ip> --path /login --method GET', run: (a, f) => require('./cli/challenges').test(a, f) },
417
+ enable: { desc: 'Enable a challenge rule', usage: 'securenow challenge enable <id>', run: (a, f) => require('./cli/challenges').enable(a, f) },
418
+ disable: { desc: 'Disable a challenge rule', usage: 'securenow challenge disable <id>', run: (a, f) => require('./cli/challenges').disable(a, f) },
419
+ remove: { desc: 'Remove a challenge rule', usage: 'securenow challenge remove <id> [--reason "..."]', run: (a, f) => require('./cli/challenges').remove(a, f) },
420
+ },
421
+ defaultSub: 'list',
422
+ },
390
423
  automation: {
391
424
  desc: 'Manage automation rules for blocklist actions',
392
425
  usage: 'securenow automation <list|defaults|show|create|update|dry-run|execute|delete> [rule-id] [options]',
@@ -722,7 +755,7 @@ function showHelp(commandName) {
722
755
  'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
723
756
  'Investigate': ['ip', 'forensics'],
724
757
  'Firewall': ['firewall'],
725
- 'Remediation': ['automation', 'ratelimit', 'blocklist', 'revoke', 'allowlist', 'trusted'],
758
+ 'Remediation': ['automation', 'ratelimit', 'challenge', 'blocklist', 'revoke', 'allowlist', 'trusted'],
726
759
  'Telemetry': ['log', 'event', 'test-span'],
727
760
  'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
728
761
  'Settings': ['instances', 'config', 'version'],