securenow 8.2.0 → 8.4.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/NPM_README.md CHANGED
@@ -259,6 +259,27 @@ npx securenow notifications read-all
259
259
 
260
260
  ### Alerting
261
261
 
262
+ ### Revoke / kill sessions (new in 8.3)
263
+
264
+ ```js
265
+ const securenow = require('securenow/sessions');
266
+ // One line: emits session.seen (feeds impossible-travel / concurrent-session
267
+ // detection) AND rejects revoked sessions. Auto-detects NextAuth/connect.sid/JWT.
268
+ app.use(securenow.guard());
269
+ // ...or check manually:
270
+ if (securenow.isRevoked({ sessionId, userId })) return res.status(401).send('re-auth');
271
+ // ...or emit-only (detection without enforcement):
272
+ app.use(securenow.capture());
273
+ ```
274
+
275
+ Revoke from the CLI (the SDK enforces within ~seconds, outbound-only, fail-open):
276
+
277
+ ```bash
278
+ npx securenow revoke session <session-id> --reason "impossible travel"
279
+ npx securenow revoke user <user-id> --duration 7d # kill all of a user's sessions
280
+ npx securenow revoke list
281
+ ```
282
+
262
283
  ### Emit custom security events (new in 8.2)
263
284
 
264
285
  ```js
package/cli/security.js CHANGED
@@ -583,6 +583,88 @@ async function blocklistList(args, flags) {
583
583
  }
584
584
  }
585
585
 
586
+ async function revokeRoute(args, flags) {
587
+ const sub = args[0];
588
+ if (sub === 'list') return revokeList(args.slice(1), flags);
589
+ if (sub === 'restore') return revokeRestore(args.slice(1), flags);
590
+ if (sub === 'session' || sub === 'user') return revokeAdd(sub, args.slice(1), flags);
591
+ ui.error('Usage: securenow revoke <session <id> | user <id> | list | restore <id>> [--reason "..."] [--duration 24h] [--app <key>] [--env <env>]');
592
+ process.exit(1);
593
+ }
594
+
595
+ async function revokeAdd(type, args, flags) {
596
+ requireAuth();
597
+ const value = args[0];
598
+ if (!value) {
599
+ ui.error(`Usage: securenow revoke ${type} <${type === 'session' ? 'session-id' : 'user-id'}> [--reason "..."] [--duration 24h] [--app <key>] [--env <env>]`);
600
+ process.exit(1);
601
+ }
602
+ const body = { type, value, source: 'cli' };
603
+ if (flags.reason) body.reason = flags.reason;
604
+ if (flags.duration) body.duration = flags.duration;
605
+ if (flags.app || flags.apps) body.applicationKey = flags.app || flags.apps;
606
+ if (flags.env || flags.environment) body.environment = flags.env || flags.environment;
607
+
608
+ const s = ui.spinner(`Revoking ${type} ${value}`);
609
+ try {
610
+ const data = await api.post('/revocations', body);
611
+ s.stop(`Revoked ${type} ${ui.truncate(value, 24)}`);
612
+ if (flags.json) { ui.json(data); return; }
613
+ const r = data.revocation || data;
614
+ ui.keyValue([
615
+ ['ID', r._id || r.id || '-'],
616
+ ['Type', r.type || type],
617
+ ['Value', r.value || value],
618
+ ['Scope', r.applicationKey || 'all apps'],
619
+ ['Expires', r.expiresAt ? new Date(r.expiresAt).toISOString() : 'never'],
620
+ ]);
621
+ } catch (err) {
622
+ s.fail('Failed to revoke');
623
+ throw err;
624
+ }
625
+ }
626
+
627
+ async function revokeList(args, flags) {
628
+ requireAuth();
629
+ const s = ui.spinner('Fetching revocations');
630
+ try {
631
+ const query = {};
632
+ if (flags.type) query.type = flags.type;
633
+ if (flags.status) query.status = flags.status;
634
+ const data = await api.get('/revocations', { query });
635
+ const entries = data.revocations || [];
636
+ s.stop(`Found ${entries.length} revocation${entries.length !== 1 ? 's' : ''}`);
637
+ if (flags.json) { ui.json(entries); return; }
638
+ console.log('');
639
+ ui.table(['ID', 'Type', 'Value', 'Scope', 'Expires'], entries.map((r) => [
640
+ ui.c.dim(ui.truncate(r._id || r.id, 12)),
641
+ r.type,
642
+ ui.truncate(r.value, 28),
643
+ r.applicationKey || ui.c.dim('all'),
644
+ r.expiresAt ? new Date(r.expiresAt).toISOString().slice(0, 16) : ui.c.dim('never'),
645
+ ]));
646
+ console.log('');
647
+ } catch (err) {
648
+ s.fail('Failed to list revocations');
649
+ throw err;
650
+ }
651
+ }
652
+
653
+ async function revokeRestore(args, flags) {
654
+ requireAuth();
655
+ const id = args[0];
656
+ if (!id) { ui.error('Usage: securenow revoke restore <id> [--reason "..."]'); process.exit(1); }
657
+ const s = ui.spinner('Restoring');
658
+ try {
659
+ const data = await api.post(`/revocations/${id}/restore`, { reason: flags.reason || '' });
660
+ s.stop('Revocation lifted');
661
+ if (flags.json) ui.json(data);
662
+ } catch (err) {
663
+ s.fail('Failed to restore');
664
+ throw err;
665
+ }
666
+ }
667
+
586
668
  async function blocklistAdd(args, flags) {
587
669
  requireAuth();
588
670
  let ip = args[0];
@@ -1337,6 +1419,7 @@ module.exports = {
1337
1419
  blocklistAdd,
1338
1420
  blocklistRemove,
1339
1421
  blocklistStats,
1422
+ revokeRoute,
1340
1423
  allowlistList,
1341
1424
  allowlistAdd,
1342
1425
  allowlistRemove,
package/cli.js CHANGED
@@ -437,6 +437,18 @@ const COMMANDS = {
437
437
  },
438
438
  defaultSub: 'list',
439
439
  },
440
+ revoke: {
441
+ desc: 'Revoke sessions/users (kill stolen sessions; the SDK enforces via securenow/sessions)',
442
+ usage: 'securenow revoke <session <id> | user <id> | list | restore <id>> [options]',
443
+ flags: { reason: 'Audit reason', duration: 'Expiry, e.g. 24h or 7d (default 7d)', app: 'Scope to app key', env: 'Scope to environment', environment: 'Alias for --env', type: 'List filter: session or user', status: 'List filter: active or restored', json: 'Output as JSON' },
444
+ sub: {
445
+ session: { desc: 'Revoke a session id', usage: 'securenow revoke session <session-id> [--reason <r>] [--duration 24h] [--app <key>]', run: (a, f) => require('./cli/security').revokeRoute(['session', ...a], f) },
446
+ user: { desc: 'Revoke all sessions of a user id', usage: 'securenow revoke user <user-id> [--reason <r>] [--duration 24h] [--app <key>]', run: (a, f) => require('./cli/security').revokeRoute(['user', ...a], f) },
447
+ list: { desc: 'List active revocations', run: (a, f) => require('./cli/security').revokeRoute(['list', ...a], f) },
448
+ restore: { desc: 'Lift a revocation', usage: 'securenow revoke restore <id> [--reason <r>]', run: (a, f) => require('./cli/security').revokeRoute(['restore', ...a], f) },
449
+ },
450
+ defaultSub: 'list',
451
+ },
440
452
  allowlist: {
441
453
  desc: 'Manage IP allowlist (only allow listed IPs)',
442
454
  usage: 'securenow allowlist <subcommand> [options]',
@@ -710,7 +722,7 @@ function showHelp(commandName) {
710
722
  'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
711
723
  'Investigate': ['ip', 'forensics'],
712
724
  'Firewall': ['firewall'],
713
- 'Remediation': ['automation', 'ratelimit', 'blocklist', 'allowlist', 'trusted'],
725
+ 'Remediation': ['automation', 'ratelimit', 'blocklist', 'revoke', 'allowlist', 'trusted'],
714
726
  'Telemetry': ['log', 'event', 'test-span'],
715
727
  'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
716
728
  'Settings': ['instances', 'config', 'version'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.2.0",
3
+ "version": "8.4.0",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",
@@ -54,6 +54,10 @@
54
54
  "types": "./events.d.ts",
55
55
  "default": "./events.js"
56
56
  },
57
+ "./sessions": {
58
+ "types": "./sessions.d.ts",
59
+ "default": "./sessions.js"
60
+ },
57
61
  "./console-instrumentation": {
58
62
  "default": "./console-instrumentation.js"
59
63
  },
@@ -118,6 +122,8 @@
118
122
  "tracing.d.ts",
119
123
  "events.js",
120
124
  "events.d.ts",
125
+ "sessions.js",
126
+ "sessions.d.ts",
121
127
  "console-instrumentation.js",
122
128
  "nextjs.js",
123
129
  "nextjs.d.ts",
package/sessions.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+
3
+ export interface GuardOptions {
4
+ /** Extract the session id from a request. Defaults to NextAuth/connect.sid cookie or hashed Bearer token. */
5
+ getSessionId?: (req: IncomingMessage) => string | null | undefined;
6
+ /** Extract the user id from a request. Defaults to the JWT `sub` claim. */
7
+ getUserId?: (req: IncomingMessage) => string | null | undefined;
8
+ /** Custom rejection handler. Default: 401 + clears known session cookies. */
9
+ onRevoked?: (req: any, res: any, next: any, ctx: { sessionId: string | null; userId: string | null }) => void;
10
+ /** Background sync interval in ms (default 30000). */
11
+ syncIntervalMs?: number;
12
+ /** Emit session.seen events for detection (default true). Set false to enforce only. */
13
+ capture?: boolean;
14
+ /** Dedup window for session.seen emission, per (session, ip), in ms (default 300000). */
15
+ captureWindowMs?: number;
16
+ }
17
+
18
+ /** Start background revocation sync (called automatically by guard()). */
19
+ export function start(options?: GuardOptions): void;
20
+
21
+ /** Force one sync now. Best-effort; never throws. */
22
+ export function syncOnce(): Promise<boolean>;
23
+
24
+ /** Is this session/user currently revoked? */
25
+ export function isRevoked(input: { sessionId?: string | null; userId?: string | null }): boolean;
26
+
27
+ /** Express-style middleware: emits session.seen (detection) AND rejects revoked sessions. Fail-open. */
28
+ export function guard(options?: GuardOptions): (req: any, res: any, next: any) => void;
29
+
30
+ /** Express-style middleware that ONLY emits session.seen (detection feed, no enforcement). */
31
+ export function capture(options?: GuardOptions): (req: any, res: any, next: any) => void;
32
+
33
+ /** Resolve the client IP from a request (X-Forwarded-For / CF / socket). */
34
+ export function resolveClientIp(req: any): string | null;
35
+
36
+ /** SHA-256 helper (used to hash raw session tokens consistently). */
37
+ export function sha256(value: string): string;
package/sessions.js ADDED
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+
3
+ // SecureNow session/user revocation enforcement (the "kill the stolen session"
4
+ // half of account-takeover response).
5
+ //
6
+ // const securenow = require('securenow/sessions');
7
+ // app.use(securenow.guard()); // auto-detects NextAuth/connect.sid/JWT sessions
8
+ // // ...or check manually in your auth middleware:
9
+ // if (securenow.isRevoked({ sessionId, userId })) return res.status(401)...
10
+ //
11
+ // The SDK pulls active revocations from SecureNow (outbound, ETag-cached) and
12
+ // checks each request locally — no inbound webhook, works behind NAT, portable
13
+ // to any language. Fail-open: a SecureNow/network outage never blocks traffic.
14
+
15
+ const crypto = require('crypto');
16
+ const appConfig = require('./app-config');
17
+ const events = require('./events');
18
+
19
+ let _entries = new Map(); // "type:value" -> expiryMs (0 = never)
20
+ let _etag = null;
21
+ let _started = false;
22
+ let _timer = null;
23
+ let _syncIntervalMs = 30000;
24
+
25
+ function sha256(value) {
26
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
27
+ }
28
+
29
+ function resolveSyncTarget() {
30
+ try {
31
+ const fw = appConfig.resolveFirewallOptions();
32
+ const apiUrl = String((fw && fw.apiUrl) || '').replace(/\/$/, '');
33
+ const apiKey = fw && fw.apiKey;
34
+ const resolvedApp = appConfig.resolveAll();
35
+ const appKey = resolvedApp && resolvedApp.appKey;
36
+ let env = 'production';
37
+ try { env = appConfig.resolveDeploymentEnvironment() || 'production'; } catch {}
38
+ if (!apiUrl || !apiKey || !appKey) return null;
39
+ const url = `${apiUrl}/api/v1/revocations/sync?app=${encodeURIComponent(appKey)}&env=${encodeURIComponent(env)}`;
40
+ return { url, apiKey, appKey };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function applyEntries(list) {
47
+ const next = new Map();
48
+ for (const e of list || []) {
49
+ if (!e || !e.type || !e.value) continue;
50
+ const exp = e.expiresAt ? Date.parse(e.expiresAt) : 0;
51
+ next.set(`${e.type}:${e.value}`, Number.isFinite(exp) ? exp : 0);
52
+ }
53
+ _entries = next;
54
+ }
55
+
56
+ function syncOnce() {
57
+ return new Promise((resolve) => {
58
+ const t = resolveSyncTarget();
59
+ if (!t) return resolve(false);
60
+ let parsed;
61
+ let lib;
62
+ try {
63
+ parsed = new (require('url').URL)(t.url);
64
+ lib = parsed.protocol === 'https:' ? require('https') : require('http');
65
+ } catch {
66
+ return resolve(false);
67
+ }
68
+ const headers = { Authorization: `Bearer ${t.apiKey}`, 'x-securenow-app-key': t.appKey };
69
+ if (_etag) headers['if-none-match'] = _etag;
70
+ const req = lib.request(
71
+ {
72
+ method: 'GET',
73
+ hostname: parsed.hostname,
74
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
75
+ path: parsed.pathname + parsed.search,
76
+ headers,
77
+ timeout: 5000,
78
+ },
79
+ (res) => {
80
+ if (res.statusCode === 304) {
81
+ res.resume();
82
+ return resolve(true);
83
+ }
84
+ const etag = res.headers.etag;
85
+ const chunks = [];
86
+ res.on('data', (c) => chunks.push(c));
87
+ res.on('end', () => {
88
+ if (res.statusCode >= 200 && res.statusCode < 300) {
89
+ try {
90
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
91
+ applyEntries(body && body.revocations && body.revocations.entries);
92
+ if (etag) _etag = etag;
93
+ } catch {}
94
+ return resolve(true);
95
+ }
96
+ return resolve(false);
97
+ });
98
+ }
99
+ );
100
+ req.on('error', () => resolve(false));
101
+ req.on('timeout', () => {
102
+ try { req.destroy(); } catch {}
103
+ resolve(false);
104
+ });
105
+ req.end();
106
+ });
107
+ }
108
+
109
+ function isExpired(exp) {
110
+ return exp && Date.now() > exp;
111
+ }
112
+
113
+ /**
114
+ * Is this session/user currently revoked? Pass whatever you have.
115
+ * @param {{sessionId?: string, userId?: string}} input
116
+ */
117
+ function isRevoked(input) {
118
+ if (!input) return false;
119
+ const sid = input.sessionId != null ? String(input.sessionId) : null;
120
+ const uid = input.userId != null ? String(input.userId) : null;
121
+ if (sid) {
122
+ const e = _entries.get(`session:${sid}`);
123
+ if (e !== undefined && !isExpired(e)) return true;
124
+ }
125
+ if (uid) {
126
+ const e = _entries.get(`user:${uid}`);
127
+ if (e !== undefined && !isExpired(e)) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ function start(options = {}) {
133
+ if (options.syncIntervalMs) _syncIntervalMs = options.syncIntervalMs;
134
+ if (_started) return;
135
+ _started = true;
136
+ syncOnce();
137
+ _timer = setInterval(syncOnce, _syncIntervalMs);
138
+ if (_timer && typeof _timer.unref === 'function') _timer.unref();
139
+ }
140
+
141
+ // --- Tier-0 auto session detection (zero app code) -------------------------
142
+ const SESSION_COOKIES = [
143
+ '__Secure-next-auth.session-token',
144
+ '__Host-next-auth.session-token',
145
+ 'next-auth.session-token',
146
+ 'connect.sid',
147
+ 'session',
148
+ 'sid',
149
+ ];
150
+
151
+ function parseCookies(header) {
152
+ const out = {};
153
+ if (!header) return out;
154
+ for (const part of String(header).split(';')) {
155
+ const i = part.indexOf('=');
156
+ if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
157
+ }
158
+ return out;
159
+ }
160
+
161
+ // Mirrors how an auto-adapter emits session.id/enduser.id: hash the raw
162
+ // cookie/token; read the JWT `sub` for the user id.
163
+ function defaultExtract(req) {
164
+ let sessionId = null;
165
+ let userId = null;
166
+ const headers = req.headers || {};
167
+ const auth = headers.authorization || headers.Authorization;
168
+ if (auth && /^Bearer\s+/i.test(auth)) {
169
+ const token = auth.replace(/^Bearer\s+/i, '').trim();
170
+ if (token) {
171
+ sessionId = sha256(token);
172
+ try {
173
+ const seg = token.split('.')[1] || '';
174
+ const payload = JSON.parse(Buffer.from(seg, 'base64').toString('utf8'));
175
+ if (payload && payload.sub) userId = String(payload.sub);
176
+ } catch {}
177
+ }
178
+ }
179
+ if (!sessionId && headers.cookie) {
180
+ const cookies = parseCookies(headers.cookie);
181
+ for (const name of SESSION_COOKIES) {
182
+ if (cookies[name]) {
183
+ sessionId = sha256(cookies[name]);
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ return { sessionId, userId };
189
+ }
190
+
191
+ // --- Tier-0 auto-emit of session.seen (feeds session-theft detection) -------
192
+ // Emitting on EVERY request would be huge volume, so we dedup per
193
+ // (session, ip) within a window — enough to capture every new
194
+ // session/network combination, which is what concurrent-network /
195
+ // impossible-travel detection needs.
196
+ const _seen = new Map(); // "sid|ip" -> lastEmitMs
197
+ const _SEEN_MAX = 5000;
198
+ let _captureWindowMs = 5 * 60 * 1000;
199
+
200
+ function resolveClientIp(req) {
201
+ const h = (req && req.headers) || {};
202
+ const xff = h['x-forwarded-for'];
203
+ if (xff) return String(Array.isArray(xff) ? xff[0] : xff).split(',')[0].trim();
204
+ if (h['cf-connecting-ip']) return String(h['cf-connecting-ip']).trim();
205
+ if (h['x-real-ip']) return String(h['x-real-ip']).trim();
206
+ const sock = req && (req.socket || req.connection);
207
+ return (sock && sock.remoteAddress) || null;
208
+ }
209
+
210
+ function emitSessionSeen(sessionId, userId, ip) {
211
+ if (!sessionId && !userId) return;
212
+ const key = `${sessionId || ''}|${ip || ''}`;
213
+ const now = Date.now();
214
+ const last = _seen.get(key);
215
+ if (last && now - last < _captureWindowMs) return; // deduped
216
+ if (_seen.size > _SEEN_MAX) _seen.clear();
217
+ _seen.set(key, now);
218
+ try {
219
+ events.track('session.seen', { sessionId, userId, ip });
220
+ } catch {
221
+ /* never throw into the app */
222
+ }
223
+ }
224
+
225
+ function extractIdentity(req, getSessionId, getUserId) {
226
+ let sessionId = null;
227
+ let userId = null;
228
+ if (getSessionId || getUserId) {
229
+ if (getSessionId) sessionId = getSessionId(req);
230
+ if (getUserId) userId = getUserId(req);
231
+ } else {
232
+ const d = defaultExtract(req);
233
+ sessionId = d.sessionId;
234
+ userId = d.userId;
235
+ }
236
+ return { sessionId, userId };
237
+ }
238
+
239
+ /**
240
+ * Middleware that ONLY emits session.seen (detection feed), no enforcement.
241
+ * Use this if you want SecureNow to see sessions but not block anything.
242
+ */
243
+ function capture(options = {}) {
244
+ if (options.captureWindowMs) _captureWindowMs = options.captureWindowMs;
245
+ const { getSessionId, getUserId } = options;
246
+ return function securenowSessionCapture(req, res, next) {
247
+ try {
248
+ const { sessionId, userId } = extractIdentity(req, getSessionId, getUserId);
249
+ emitSessionSeen(sessionId, userId, resolveClientIp(req));
250
+ } catch {
251
+ /* fail-open */
252
+ }
253
+ return next();
254
+ };
255
+ }
256
+
257
+ function defaultOnRevoked(req, res) {
258
+ try {
259
+ const clears = SESSION_COOKIES.map((n) => `${n}=; Path=/; Max-Age=0; HttpOnly`);
260
+ if (typeof res.setHeader === 'function') res.setHeader('Set-Cookie', clears);
261
+ } catch {}
262
+ if (typeof res.status === 'function' && typeof res.json === 'function') {
263
+ return res.status(401).json({ error: 'Session revoked. Please sign in again.', code: 'session_revoked' });
264
+ }
265
+ res.statusCode = 401;
266
+ try { res.end('Session revoked'); } catch {}
267
+ }
268
+
269
+ /**
270
+ * Express-style middleware that rejects requests whose session/user is revoked.
271
+ * Starts background sync on first use. Fail-open and never throws.
272
+ *
273
+ * @param {object} [options]
274
+ * @param {(req)=>string} [options.getSessionId] Custom session id extractor.
275
+ * @param {(req)=>string} [options.getUserId] Custom user id extractor.
276
+ * @param {(req,res,next,ctx)=>void} [options.onRevoked] Custom rejection handler.
277
+ * @param {number} [options.syncIntervalMs]
278
+ */
279
+ function guard(options = {}) {
280
+ start(options);
281
+ if (options.captureWindowMs) _captureWindowMs = options.captureWindowMs;
282
+ const { getSessionId, getUserId } = options;
283
+ const onRevoked = options.onRevoked || defaultOnRevoked;
284
+ const doCapture = options.capture !== false; // emit session.seen by default
285
+ return function securenowSessionGuard(req, res, next) {
286
+ try {
287
+ const { sessionId, userId } = extractIdentity(req, getSessionId, getUserId);
288
+ // Detect: feed session-theft detection (deduped, fire-and-forget).
289
+ if (doCapture) emitSessionSeen(sessionId, userId, resolveClientIp(req));
290
+ // Respond: reject if this session/user has been revoked.
291
+ if (isRevoked({ sessionId, userId })) {
292
+ return onRevoked(req, res, next, { sessionId, userId });
293
+ }
294
+ } catch {
295
+ /* fail-open */
296
+ }
297
+ return next();
298
+ };
299
+ }
300
+
301
+ module.exports = { start, syncOnce, isRevoked, guard, capture, resolveClientIp, sha256 };