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/SKILL-CLI.md +17 -3
- package/challenge.js +273 -0
- package/cli/challenges.js +253 -0
- package/cli/security.js +134 -7
- package/cli.js +36 -3
- package/firewall.js +952 -702
- package/package.json +5 -1
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',
|
|
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 ${
|
|
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
|
-
|
|
449
|
+
let id = args[0];
|
|
335
450
|
const candidateSqlQuery = readSqlArg(flags);
|
|
336
|
-
if (!
|
|
337
|
-
ui.error('Usage: securenow alerts rules
|
|
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 (
|
|
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'],
|