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/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
|
|
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
|
|
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 — no action needed.</p>
|
|
218
|
+
<div class="spinner" id="sp"></div>
|
|
219
|
+
<p class="status" id="st">Starting verification…</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 — 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 };
|