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 CHANGED
@@ -255,23 +255,37 @@ Write tools still require `confirm:true` plus a reason. False positives should s
255
255
  ```bash
256
256
  securenow alerts # list alert rules (default)
257
257
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
258
- securenow alerts rules create \ # NEW: create a custom detection rule from inline SQL
258
+ securenow alerts rules validate \ # dry-run a candidate query BEFORE creating any rule
259
+ --sql @rule.sql --app <key> # no rule id needed — auto-hosts on an existing (system) rule
260
+ securenow alerts rules create \ # create a custom detection rule from inline SQL
259
261
  --name "Auth: magic-link brute force" \
260
262
  --sql @rule.sql \ # SQL, @file, or - for stdin; scope apps with __USER_APP_KEYS__
261
263
  --apps key1,key2 \ # or --applications-all
262
264
  --severity high --schedule "*/15 * * * *" \
263
265
  --nlp "single IP flooding /api/auth/signin or /api/auth/callback"
266
+ # create auto dry-runs + rolls back (deletes) on query failure; --no-validate to skip
264
267
  securenow alerts rules show <id> # one rule; JSON: --json
268
+ securenow alerts rules set-sql <id> --sql @new.sql # replace a CUSTOM rule's SQL in place (alias: update <id> --sql)
269
+ securenow alerts rules delete <id> --yes # delete a custom rule (system rules: disable instead)
265
270
  securenow alerts rules update <id> --applications-all # all current & future apps
266
271
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
267
- securenow alerts rules test <id> --mode dry_run --wait # validate a rule query
268
- securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait
272
+ securenow alerts rules test <id> --mode dry_run --wait # validate an existing rule's query
273
+ securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait # candidate test against a specific host rule
269
274
  securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
270
275
  securenow alerts rules exclusions <id> list # embedded rule exclusions
271
276
  securenow alerts channels # list alert channels (Slack, email, etc.)
272
277
  securenow alerts history [--limit N] # past triggered alerts
273
278
  ```
274
279
 
280
+ **Authoring loop that won't strand a broken rule:** `validate --sql @rule.sql --app <key>`
281
+ (dry-run before anything exists) → `create …` (which itself dry-runs and auto-rolls-back on
282
+ failure) → if you later need to fix it, `set-sql <id> --sql @rule.sql` (custom rules) or
283
+ `delete <id>`. Detection `.sql` files may start with `--`/`/* */` comments. Custom-rule SQL is
284
+ editable; **system**-rule SQL changes go through `tune-query … --apply-globally`. Pick the
285
+ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) use
286
+ `resources_string['service.name'] IN (__USER_APP_KEYS__)`, traces
287
+ (`signoz_traces.distributed_signoz_index_v3`) use `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``.
288
+
275
289
  `alerts rules create` flags: `--name` (required); one of `--sql <sql|@file|->` or `--query-mapping-id <id>`; one of `--apps k1,k2` or `--applications-all`; optional `--description`, `--nlp` (plain-English intent), `--category`, `--severity critical|high|medium|low`, `--schedule <cron>` (default `*/15 * * * *`), `--throttle-minutes N` / `--no-throttle`, `--execution-mode scheduled|instant|hybrid`, `--channel id1,id2` (defaults to your in-app SecureNow channel). Detection SQL must scope app keys via the `__USER_APP_KEYS__` placeholder and select an `ip` column for per-IP aggregation/remediation. Requires `alerts:write`.
276
290
 
277
291
  MCP parity for noisy alert-rule reviews:
package/challenge.js ADDED
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ // SecureNow homegrown challenge protocol (v1 = invisible Proof-of-Work).
4
+ //
5
+ // This is the SDK-side half of a stateless, signed challenge handshake. The
6
+ // API side (securenow-api/src/libs/challengeCrypto.js) implements the exact
7
+ // same wire format so a challenge minted on one side can be verified on the
8
+ // other. Nothing is stored server-side: a challenge token and a clearance
9
+ // cookie are both self-describing, HMAC-signed envelopes.
10
+ //
11
+ // Flow:
12
+ // 1. A request matches a challenge rule and carries no valid clearance.
13
+ // 2. We mint a challenge token (signChallenge) and serve an interstitial that
14
+ // asks the browser to find a `nonce` such that SHA-256(token + '.' + nonce)
15
+ // has `diff` leading zero bits. That is the proof-of-work — invisible to a
16
+ // human (a sub-second JS loop) but a per-request CPU tax on a flood.
17
+ // 3. The browser POSTs {token, nonce} back. We verify the work
18
+ // (verifyChallengeSolution) and, on success, issue a clearance cookie
19
+ // (issueClearance) bound to the IP + User-Agent for a short TTL.
20
+ // 4. Subsequent requests present the cookie; verifyClearance lets them pass.
21
+ //
22
+ // `alg` is versioned so future challenge types (interactive, behavioural,
23
+ // device-fingerprint) slot in behind the same token/cookie machinery without
24
+ // touching the rule model, sync payload, or clearance handling.
25
+
26
+ const crypto = require('crypto');
27
+
28
+ const CHALLENGE_PREFIX = 'c1';
29
+ const CLEARANCE_PREFIX = 'k1';
30
+ const ALG_POW = 'pow-sha256';
31
+ const CLEARANCE_COOKIE = '__sn_clearance';
32
+
33
+ const DEFAULTS = {
34
+ difficulty: 14, // leading zero bits; ~2^14 hashes expected (sub-second in JS)
35
+ challengeTtl: 120, // seconds a minted challenge stays solvable
36
+ clearanceTtl: 1800, // seconds a solved clearance lasts (30 min)
37
+ };
38
+
39
+ // ── low-level helpers ──────────────────────────────────────────────────────
40
+
41
+ function b64urlEncode(str) {
42
+ return Buffer.from(str, 'utf8').toString('base64url');
43
+ }
44
+
45
+ function b64urlDecode(str) {
46
+ return Buffer.from(String(str), 'base64url').toString('utf8');
47
+ }
48
+
49
+ function nowSec() {
50
+ return Math.floor(Date.now() / 1000);
51
+ }
52
+
53
+ function hmac(secret, data) {
54
+ return crypto.createHmac('sha256', String(secret || '')).update(data).digest('base64url');
55
+ }
56
+
57
+ function timingEqual(a, b) {
58
+ const ba = Buffer.from(String(a));
59
+ const bb = Buffer.from(String(b));
60
+ if (ba.length !== bb.length) return false;
61
+ return crypto.timingSafeEqual(ba, bb);
62
+ }
63
+
64
+ function normIp(ip) {
65
+ return String(ip || '').replace(/^::ffff:/i, '').trim();
66
+ }
67
+
68
+ function uaHash(ua) {
69
+ return crypto.createHash('sha256').update(String(ua || '')).digest('hex').slice(0, 16);
70
+ }
71
+
72
+ // Count leading zero BITS of a hash buffer. Bit-level granularity lets a single
73
+ // `difficulty` knob scale work smoothly instead of in 16x jumps per hex nibble.
74
+ function leadingZeroBits(buf) {
75
+ let bits = 0;
76
+ for (let i = 0; i < buf.length; i++) {
77
+ const byte = buf[i];
78
+ if (byte === 0) {
79
+ bits += 8;
80
+ continue;
81
+ }
82
+ bits += Math.clz32(byte) - 24; // clz32 of an 8-bit value, minus the 24 high zero bits
83
+ break;
84
+ }
85
+ return bits;
86
+ }
87
+
88
+ function meetsDifficulty(buf, diff) {
89
+ return leadingZeroBits(buf) >= Math.max(0, Number(diff) || 0);
90
+ }
91
+
92
+ function parseSigned(prefix, token) {
93
+ if (typeof token !== 'string') return null;
94
+ const parts = token.split('.');
95
+ if (parts.length !== 3 || parts[0] !== prefix) return null;
96
+ let payload;
97
+ try {
98
+ payload = JSON.parse(b64urlDecode(parts[1]));
99
+ } catch (_) {
100
+ return null;
101
+ }
102
+ return { body: `${parts[0]}.${parts[1]}`, payload, sig: parts[2] };
103
+ }
104
+
105
+ // ── challenge token ────────────────────────────────────────────────────────
106
+
107
+ function signChallenge(secret, opts = {}) {
108
+ const iat = nowSec();
109
+ const ttl = Number(opts.challengeTtl || DEFAULTS.challengeTtl);
110
+ const payload = {
111
+ ip: normIp(opts.ip),
112
+ alg: opts.alg || ALG_POW,
113
+ diff: Math.max(1, Number(opts.difficulty || DEFAULTS.difficulty)),
114
+ rid: opts.rid || '*',
115
+ iat,
116
+ exp: iat + ttl,
117
+ };
118
+ const body = `${CHALLENGE_PREFIX}.${b64urlEncode(JSON.stringify(payload))}`;
119
+ return `${body}.${hmac(secret, body)}`;
120
+ }
121
+
122
+ function verifyChallengeSolution(secret, { token, nonce, ip } = {}) {
123
+ const parsed = parseSigned(CHALLENGE_PREFIX, token);
124
+ if (!parsed) return { ok: false, reason: 'malformed' };
125
+ if (!timingEqual(parsed.sig, hmac(secret, parsed.body))) return { ok: false, reason: 'bad_signature' };
126
+ const p = parsed.payload;
127
+ if (p.exp && nowSec() > p.exp) return { ok: false, reason: 'expired' };
128
+ if (ip && normIp(ip) !== p.ip) return { ok: false, reason: 'ip_mismatch' };
129
+ if (p.alg !== ALG_POW) return { ok: false, reason: 'unsupported_alg' };
130
+ if (nonce === undefined || nonce === null || `${nonce}` === '') return { ok: false, reason: 'missing_nonce' };
131
+ const digest = crypto.createHash('sha256').update(`${token}.${nonce}`).digest();
132
+ if (!meetsDifficulty(digest, p.diff)) return { ok: false, reason: 'insufficient_work' };
133
+ return { ok: true, payload: p };
134
+ }
135
+
136
+ // ── clearance cookie ───────────────────────────────────────────────────────
137
+
138
+ function issueClearance(secret, opts = {}) {
139
+ const iat = nowSec();
140
+ const ttl = Number(opts.clearanceTtl || DEFAULTS.clearanceTtl);
141
+ const payload = {
142
+ ip: normIp(opts.ip),
143
+ uah: uaHash(opts.ua),
144
+ rid: opts.rid || '*',
145
+ iat,
146
+ exp: iat + ttl,
147
+ };
148
+ const body = `${CLEARANCE_PREFIX}.${b64urlEncode(JSON.stringify(payload))}`;
149
+ return { value: `${body}.${hmac(secret, body)}`, maxAge: ttl, exp: payload.exp };
150
+ }
151
+
152
+ function verifyClearance(secret, { cookie, ip, ua } = {}) {
153
+ const parsed = parseSigned(CLEARANCE_PREFIX, cookie);
154
+ if (!parsed) return { ok: false, reason: 'malformed' };
155
+ if (!timingEqual(parsed.sig, hmac(secret, parsed.body))) return { ok: false, reason: 'bad_signature' };
156
+ const p = parsed.payload;
157
+ if (p.exp && nowSec() > p.exp) return { ok: false, reason: 'expired' };
158
+ if (ip && normIp(ip) !== p.ip) return { ok: false, reason: 'ip_mismatch' };
159
+ if (ua && uaHash(ua) !== p.uah) return { ok: false, reason: 'ua_mismatch' };
160
+ return { ok: true, payload: p };
161
+ }
162
+
163
+ function readClearanceCookie(req) {
164
+ const header = req && req.headers && req.headers['cookie'];
165
+ if (!header) return null;
166
+ for (const part of String(header).split(';')) {
167
+ const idx = part.indexOf('=');
168
+ if (idx === -1) continue;
169
+ if (part.slice(0, idx).trim() === CLEARANCE_COOKIE) {
170
+ return part.slice(idx + 1).trim();
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // ── interstitial page ──────────────────────────────────────────────────────
177
+ // Invisible PoW solver. Uses SubtleCrypto (available in every modern browser);
178
+ // the leading-zero-bits check mirrors leadingZeroBits() above exactly so a
179
+ // solution accepted by the browser verifies on the server.
180
+
181
+ function challengeHtml({ token, difficulty, submitPath, returnPath, ip }) {
182
+ const cfg = JSON.stringify({
183
+ token: String(token),
184
+ diff: Math.max(1, Number(difficulty) || DEFAULTS.difficulty),
185
+ submit: String(submitPath || '/__securenow/challenge'),
186
+ ret: String(returnPath || '/'),
187
+ });
188
+ const safeIp = String(ip || 'unknown').replace(/[<>&"]/g, '');
189
+ return `<!DOCTYPE html>
190
+ <html lang="en">
191
+ <head>
192
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
193
+ <title>Verifying your browser - SecureNow</title>
194
+ <style>
195
+ *{margin:0;padding:0;box-sizing:border-box}
196
+ body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5}
197
+ .wrap{text-align:center;max-width:540px;padding:2.5rem 2rem}
198
+ .icon{width:64px;height:64px;margin:0 auto 1.5rem;border-radius:50%;background:rgba(59,130,246,.12);display:flex;align-items:center;justify-content:center}
199
+ .icon svg{width:32px;height:32px;fill:none;stroke:#3b82f6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
200
+ .badge{display:inline-block;padding:.25rem .75rem;border-radius:999px;background:rgba(59,130,246,.15);color:#93c5fd;font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:1.25rem}
201
+ h1{font-size:1.5rem;font-weight:700;margin-bottom:.6rem;color:#fff}
202
+ p{font-size:.9rem;line-height:1.7;color:#a1a1aa;margin-bottom:.5rem}
203
+ .spinner{width:34px;height:34px;margin:1.5rem auto .5rem;border:3px solid rgba(255,255,255,.12);border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite}
204
+ @keyframes spin{to{transform:rotate(360deg)}}
205
+ .status{font-size:.82rem;color:#71717a;margin-top:.75rem;min-height:1.2em}
206
+ .ip-box{display:inline-block;margin:1rem 0 .25rem;padding:.5rem 1.25rem;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(59,130,246,.25);font-family:"SF Mono",SFMono-Regular,Consolas,Menlo,monospace;font-size:.85rem;color:#93c5fd}
207
+ .footer{margin-top:2rem;font-size:.7rem;color:#3f3f46}
208
+ .powered{margin-top:1.5rem;font-size:.75rem;color:#52525b}.powered a{color:#a1a1aa;text-decoration:none;font-weight:600}
209
+ noscript{color:#f87171}
210
+ </style>
211
+ </head>
212
+ <body>
213
+ <div class="wrap">
214
+ <div class="icon"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
215
+ <span class="badge">Security Check</span>
216
+ <h1>Verifying your browser</h1>
217
+ <p>Automated traffic from your network triggered an extra verification step. This runs automatically &mdash; no action needed.</p>
218
+ <div class="spinner" id="sp"></div>
219
+ <p class="status" id="st">Starting verification&hellip;</p>
220
+ <div class="ip-box">${safeIp}</div>
221
+ <noscript><p>JavaScript is required to complete this verification.</p></noscript>
222
+ <p class="footer">Protected by SecureNow &mdash; proof-of-work challenge</p>
223
+ <p class="powered">Protected by <a href="https://securenow.ai" rel="dofollow" target="_blank">SecureNow</a></p>
224
+ </div>
225
+ <script>
226
+ (function(){
227
+ var C=${cfg};
228
+ var st=document.getElementById('st');
229
+ function setStatus(t){ if(st) st.textContent=t; }
230
+ function lzbits(bytes){var bits=0;for(var i=0;i<bytes.length;i++){var b=bytes[i];if(b===0){bits+=8;continue;}bits+=Math.clz32(b)-24;break;}return bits;}
231
+ if(!(window.crypto&&window.crypto.subtle)){setStatus('Your browser does not support this verification.');return;}
232
+ var enc=new TextEncoder();
233
+ var nonce=0,started=Date.now();
234
+ function step(){
235
+ var batch=0;
236
+ (function loop(){
237
+ if(batch++>400){ setStatus('Verifying… ('+nonce+' attempts)'); setTimeout(step,0); return; }
238
+ var data=enc.encode(C.token+'.'+nonce);
239
+ window.crypto.subtle.digest('SHA-256',data).then(function(buf){
240
+ var bytes=new Uint8Array(buf);
241
+ if(lzbits(bytes)>=C.diff){ submit(nonce); return; }
242
+ nonce++; loop();
243
+ }).catch(function(){ setStatus('Verification error. Please refresh.'); });
244
+ })();
245
+ }
246
+ function submit(n){
247
+ setStatus('Solved. Finalizing…');
248
+ fetch(C.submit,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({token:C.token,nonce:String(n)})})
249
+ .then(function(r){ if(r.ok){ window.location.replace(C.ret); } else { return r.text().then(function(){ setStatus('Verification rejected. Refreshing…'); setTimeout(function(){window.location.reload();},1500); }); } })
250
+ .catch(function(){ setStatus('Network error. Refreshing…'); setTimeout(function(){window.location.reload();},1500); });
251
+ }
252
+ step();
253
+ })();
254
+ </script>
255
+ </body>
256
+ </html>`;
257
+ }
258
+
259
+ module.exports = {
260
+ CHALLENGE_PREFIX,
261
+ CLEARANCE_PREFIX,
262
+ CLEARANCE_COOKIE,
263
+ ALG_POW,
264
+ DEFAULTS,
265
+ signChallenge,
266
+ verifyChallengeSolution,
267
+ issueClearance,
268
+ verifyClearance,
269
+ readClearanceCookie,
270
+ challengeHtml,
271
+ meetsDifficulty,
272
+ leadingZeroBits,
273
+ };
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ const { api, requireAuth } = require('./client');
4
+ const config = require('./config');
5
+ const ui = require('./ui');
6
+
7
+ function resolveApp(flags) {
8
+ return flags.app || config.getDefaultApp();
9
+ }
10
+
11
+ function resolveEnvironment(flags, fallback = null) {
12
+ return flags.env || flags.environment || fallback;
13
+ }
14
+
15
+ function parseTtl(value, fallback = 1800) {
16
+ if (value == null || value === '') return fallback;
17
+ if (/^\d+$/.test(String(value))) return Number(value);
18
+ const raw = String(value).trim().toLowerCase();
19
+ const match = raw.match(/^(\d+)\s*(s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours)$/);
20
+ if (!match) {
21
+ ui.error('Clearance must be seconds or look like 30m, 1h, or 12h.');
22
+ process.exit(1);
23
+ }
24
+ const n = Number(match[1]);
25
+ const unit = match[2][0];
26
+ return unit === 'h' ? n * 3600 : unit === 'm' ? n * 60 : n;
27
+ }
28
+
29
+ function buildQuery(flags) {
30
+ const query = {};
31
+ if (flags.page) query.page = flags.page;
32
+ if (flags.limit) query.limit = flags.limit;
33
+ if (flags.status) query.status = flags.status;
34
+ if (flags.search) query.search = flags.search;
35
+ const appKey = resolveApp(flags);
36
+ if (appKey) query.appKey = appKey;
37
+ const environment = resolveEnvironment(flags, null);
38
+ if (environment) query.environment = environment;
39
+ return query;
40
+ }
41
+
42
+ function describeTarget(rule) {
43
+ const parts = [];
44
+ if (rule.ip) parts.push(rule.ip);
45
+ if (rule.method && rule.method !== 'ALL') parts.push(rule.method);
46
+ if (rule.pathPattern) parts.push(`${rule.pathMatchMode || 'prefix'}:${rule.pathPattern}`);
47
+ return parts.join(' ') || 'all traffic';
48
+ }
49
+
50
+ function describeClearance(rule) {
51
+ const ttl = rule.clearanceTtlSeconds || 1800;
52
+ return ttl % 3600 === 0 ? `${ttl / 3600}h` : ttl % 60 === 0 ? `${ttl / 60}m` : `${ttl}s`;
53
+ }
54
+
55
+ async function list(args, flags) {
56
+ requireAuth();
57
+ const s = ui.spinner('Fetching challenge rules');
58
+ try {
59
+ const data = await api.get('/challenges', { query: buildQuery(flags) });
60
+ const rules = data.challenges || [];
61
+ s.stop(`Found ${rules.length} challenge rule${rules.length !== 1 ? 's' : ''}${data.total ? ` (${data.total} total)` : ''}`);
62
+
63
+ if (flags.json) { ui.json(data); return; }
64
+
65
+ console.log('');
66
+ const rows = rules.map((r) => [
67
+ ui.c.dim(ui.truncate(r.id || r._id, 12)),
68
+ r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
69
+ describeTarget(r),
70
+ `pow:${r.difficulty}`,
71
+ describeClearance(r),
72
+ r.applicationKey || ui.c.dim('all apps'),
73
+ r.environment || ui.c.dim('all envs'),
74
+ r.expiresAt ? new Date(r.expiresAt).toLocaleString() : ui.c.dim('permanent'),
75
+ ]);
76
+ ui.table(['ID', 'Status', 'Target', 'Strength', 'Clears', 'App', 'Env', 'Expires'], rows);
77
+ console.log('');
78
+ } catch (err) {
79
+ s.fail('Failed to fetch challenge rules');
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ async function show(args, flags) {
85
+ requireAuth();
86
+ const id = args[0];
87
+ if (!id) {
88
+ ui.error('Usage: securenow challenge show <id>');
89
+ process.exit(1);
90
+ }
91
+ const s = ui.spinner('Fetching challenge rule');
92
+ try {
93
+ const data = await api.get(`/challenges/${id}`);
94
+ const r = data.challenge;
95
+ s.stop('Challenge rule loaded');
96
+ if (flags.json) { ui.json(r); return; }
97
+ console.log('');
98
+ ui.heading(r.name || 'Challenge rule');
99
+ console.log('');
100
+ ui.keyValue([
101
+ ['ID', r.id || r._id || id],
102
+ ['Status', r.status || '-'],
103
+ ['Target', describeTarget(r)],
104
+ ['Strength', `proof-of-work, ${r.difficulty} bits`],
105
+ ['Clearance', describeClearance(r)],
106
+ ['Escalate to block', r.escalation?.escalateToBlock ? `after ${r.escalation.failThreshold} fails (${r.escalation.blockTtlHours}h)` : 'no'],
107
+ ['App', r.applicationKey || 'all apps'],
108
+ ['Environment', r.environment || 'all envs'],
109
+ ['Reason', r.reason || '-'],
110
+ ['Expires', r.expiresAt || 'permanent'],
111
+ ]);
112
+ console.log('');
113
+ } catch (err) {
114
+ s.fail('Failed to fetch challenge rule');
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ async function add(args, flags) {
120
+ requireAuth();
121
+ const ip = args[0] || flags.ip || '';
122
+ const pathPattern = flags.route || flags.path || flags.pattern || '';
123
+
124
+ if (!ip && !pathPattern) {
125
+ ui.error('Provide an IP/CIDR, --route, or both.');
126
+ ui.info('Example: securenow challenge add --route /login --difficulty 16 --clearance 30m');
127
+ process.exit(1);
128
+ }
129
+
130
+ const appKey = resolveApp(flags);
131
+ const environment = resolveEnvironment(flags, 'production');
132
+ const body = {
133
+ ip,
134
+ pathPattern,
135
+ pathMatchMode: flags.mode || flags['path-mode'] || 'prefix',
136
+ method: flags.method || 'ALL',
137
+ difficulty: flags.difficulty ? Number(flags.difficulty) : undefined,
138
+ clearanceTtlSeconds: parseTtl(flags.clearance || flags['clearance-ttl']),
139
+ reason: flags.reason || '',
140
+ name: flags.name || '',
141
+ duration: flags.duration || '',
142
+ expiresAt: flags.expiresAt || flags.expires || '',
143
+ };
144
+ if (flags['escalate-to-block'] || flags.escalate) {
145
+ body.escalation = {
146
+ escalateToBlock: true,
147
+ failThreshold: flags['fail-threshold'] ? Number(flags['fail-threshold']) : 10,
148
+ blockTtlHours: flags['block-ttl-hours'] ? Number(flags['block-ttl-hours']) : 24,
149
+ };
150
+ }
151
+ if (appKey) body.appKey = appKey;
152
+ if (environment) body.environment = environment;
153
+
154
+ const s = ui.spinner('Creating challenge rule');
155
+ try {
156
+ const data = await api.post('/challenges', body);
157
+ s.stop('Challenge rule created');
158
+ if (flags.json) { ui.json(data); return; }
159
+ const r = data.challenge;
160
+ console.log('');
161
+ console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} - proof-of-work ${r.difficulty} bits, clears ${describeClearance(r)}`);
162
+ console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
163
+ console.log('');
164
+ } catch (err) {
165
+ s.fail('Failed to create challenge rule');
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ async function setStatus(args, flags, status) {
171
+ requireAuth();
172
+ const id = args[0];
173
+ if (!id) {
174
+ ui.error(`Usage: securenow challenge ${status === 'active' ? 'enable' : 'disable'} <id>`);
175
+ process.exit(1);
176
+ }
177
+ const s = ui.spinner(`${status === 'active' ? 'Enabling' : 'Disabling'} challenge rule`);
178
+ try {
179
+ const data = await api.put(`/challenges/${id}`, { status });
180
+ s.stop(status === 'active' ? 'Challenge rule enabled' : 'Challenge rule disabled');
181
+ if (flags.json) ui.json(data);
182
+ } catch (err) {
183
+ s.fail('Failed to update challenge rule');
184
+ throw err;
185
+ }
186
+ }
187
+
188
+ async function enable(args, flags) { return setStatus(args, flags, 'active'); }
189
+ async function disable(args, flags) { return setStatus(args, flags, 'disabled'); }
190
+
191
+ async function remove(args, flags) {
192
+ requireAuth();
193
+ const id = args[0];
194
+ if (!id) {
195
+ ui.error('Usage: securenow challenge remove <id> [--reason "..."]');
196
+ process.exit(1);
197
+ }
198
+ if (!flags.force && !flags.yes) {
199
+ const ok = await ui.confirm('Remove this challenge rule?');
200
+ if (!ok) { ui.info('Cancelled'); return; }
201
+ }
202
+ const s = ui.spinner('Removing challenge rule');
203
+ try {
204
+ const opts = flags.reason ? { query: { reason: flags.reason } } : undefined;
205
+ const data = await api.delete(`/challenges/${id}`, opts);
206
+ s.stop('Challenge rule removed');
207
+ if (flags.json) ui.json(data);
208
+ } catch (err) {
209
+ s.fail('Failed to remove challenge rule');
210
+ throw err;
211
+ }
212
+ }
213
+
214
+ async function test(args, flags) {
215
+ requireAuth();
216
+ const ip = args[0] || flags.ip;
217
+ if (!ip) {
218
+ ui.error('Usage: securenow challenge test <ip> --path /login [--method GET]');
219
+ process.exit(1);
220
+ }
221
+ const query = {
222
+ ip,
223
+ path: flags.path || flags.route || '/',
224
+ method: flags.method || 'GET',
225
+ };
226
+ const appKey = resolveApp(flags);
227
+ if (appKey) query.appKey = appKey;
228
+ const environment = resolveEnvironment(flags, 'production');
229
+ if (environment) query.environment = environment;
230
+
231
+ const s = ui.spinner('Testing challenge match');
232
+ try {
233
+ const data = await api.get('/challenges/check', { query });
234
+ s.stop('Challenge check complete');
235
+ if (flags.json) { ui.json(data); return; }
236
+ console.log('');
237
+ if (data.challenged) {
238
+ console.log(` ${ui.c.bold(ui.c.yellow('CHALLENGED'))} - ${data.matches.length} matching rule${data.matches.length !== 1 ? 's' : ''}`);
239
+ for (const r of data.matches.slice(0, 5)) {
240
+ console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} pow:${r.difficulty}`);
241
+ }
242
+ } else {
243
+ console.log(` ${ui.c.bold(ui.c.green('NOT CHALLENGED'))} - no matching challenge rules`);
244
+ }
245
+ console.log(` ${ui.c.dim(`App scope: ${appKey || 'all apps'} - Environment: ${data.environment || environment}`)}`);
246
+ console.log('');
247
+ } catch (err) {
248
+ s.fail('Failed to test challenge');
249
+ throw err;
250
+ }
251
+ }
252
+
253
+ module.exports = { list, show, add, enable, disable, remove, test };