securenow 8.5.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/challenge.js +273 -0
- package/cli/challenges.js +253 -0
- package/cli.js +32 -1
- package/firewall.js +952 -702
- package/package.json +5 -1
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 };
|
package/cli.js
CHANGED
|
@@ -389,6 +389,37 @@ const COMMANDS = {
|
|
|
389
389
|
},
|
|
390
390
|
defaultSub: 'list',
|
|
391
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
|
+
},
|
|
392
423
|
automation: {
|
|
393
424
|
desc: 'Manage automation rules for blocklist actions',
|
|
394
425
|
usage: 'securenow automation <list|defaults|show|create|update|dry-run|execute|delete> [rule-id] [options]',
|
|
@@ -724,7 +755,7 @@ function showHelp(commandName) {
|
|
|
724
755
|
'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
|
|
725
756
|
'Investigate': ['ip', 'forensics'],
|
|
726
757
|
'Firewall': ['firewall'],
|
|
727
|
-
'Remediation': ['automation', 'ratelimit', 'blocklist', 'revoke', 'allowlist', 'trusted'],
|
|
758
|
+
'Remediation': ['automation', 'ratelimit', 'challenge', 'blocklist', 'revoke', 'allowlist', 'trusted'],
|
|
728
759
|
'Telemetry': ['log', 'event', 'test-span'],
|
|
729
760
|
'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
|
|
730
761
|
'Settings': ['instances', 'config', 'version'],
|