securenow 8.1.0 → 8.3.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 +43 -0
- package/cli/diagnostics.js +48 -1
- package/cli/security.js +83 -0
- package/cli.js +35 -2
- package/events.d.ts +29 -0
- package/events.js +160 -0
- package/package.json +13 -1
- package/sessions.d.ts +27 -0
- package/sessions.js +238 -0
package/NPM_README.md
CHANGED
|
@@ -259,6 +259,49 @@ 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
|
+
app.use(securenow.guard()); // auto-detects NextAuth/connect.sid/JWT sessions; rejects revoked ones (401)
|
|
267
|
+
// ...or check manually:
|
|
268
|
+
if (securenow.isRevoked({ sessionId, userId })) return res.status(401).send('re-auth');
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Revoke from the CLI (the SDK enforces within ~seconds, outbound-only, fail-open):
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
npx securenow revoke session <session-id> --reason "impossible travel"
|
|
275
|
+
npx securenow revoke user <user-id> --duration 7d # kill all of a user's sessions
|
|
276
|
+
npx securenow revoke list
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Emit custom security events (new in 8.2)
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
const { track } = require('securenow/events');
|
|
283
|
+
|
|
284
|
+
// Fire-and-forget — batched, async, never throws into your app.
|
|
285
|
+
track('auth.login.success', {
|
|
286
|
+
userId, // enduser.id (durable identity for correlation)
|
|
287
|
+
sessionId, // session.id
|
|
288
|
+
ip, // end-user IP (enriched to geo/ASN server-side)
|
|
289
|
+
attributes: { method: 'magic_link', new_device: true },
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Events become queryable by alert rules immediately (`attributes_string['event.type']`, `['enduser.id']`, `['session.id']`, `['http.client_ip']`). Non-JS apps emit the same thing with a plain POST to `/v1/events`:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
POST https://<your-ingest-host>/v1/events
|
|
297
|
+
Authorization: Bearer snk_live_...
|
|
298
|
+
X-SecureNow-App-Key: <app-uuid>
|
|
299
|
+
|
|
300
|
+
{ "events": [ { "type": "auth.login.success", "user_id": "u_1", "session_id": "s_1", "ip": "1.2.3.4" } ] }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
CLI: `npx securenow event send auth.login.failure --user u_1 --ip 1.2.3.4 --attrs reason=bad_token`
|
|
304
|
+
|
|
262
305
|
```bash
|
|
263
306
|
# Create a custom detection rule from your own SQL (new in 8.1)
|
|
264
307
|
npx securenow alerts rules create \
|
package/cli/diagnostics.js
CHANGED
|
@@ -527,4 +527,51 @@ async function doctor(_args, flags) {
|
|
|
527
527
|
process.exit(ok ? 0 : 1);
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
|
|
530
|
+
async function eventSend(args, flags) {
|
|
531
|
+
const type = (args[0] || '').trim();
|
|
532
|
+
if (!type) {
|
|
533
|
+
ui.error('Missing event type.');
|
|
534
|
+
console.log(` ${ui.c.bold('Usage:')} securenow event send <type> [--user <id>] [--session <id>] [--ip <ip>] [--user-agent <ua>] [--level info|warn|error] [--attrs k=v,k=v]`);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const cfg = resolvedConfig(flags);
|
|
539
|
+
const eventsEndpoint = `${String(cfg.instance || '').replace(/\/$/, '')}/v1/events`;
|
|
540
|
+
|
|
541
|
+
const attributes = {};
|
|
542
|
+
if (flags.attrs) {
|
|
543
|
+
for (const pair of String(flags.attrs).split(',')) {
|
|
544
|
+
const [k, ...rest] = pair.split('=');
|
|
545
|
+
if (k && rest.length) attributes[k.trim()] = rest.join('=').trim();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const ev = { type, ts: Date.now() };
|
|
550
|
+
if (flags.user) ev.user_id = String(flags.user);
|
|
551
|
+
if (flags.session) ev.session_id = String(flags.session);
|
|
552
|
+
if (flags.ip) ev.ip = String(flags.ip);
|
|
553
|
+
if (flags['user-agent']) ev.user_agent = String(flags['user-agent']);
|
|
554
|
+
if (flags.level) ev.severity = String(flags.level);
|
|
555
|
+
if (Object.keys(attributes).length) ev.attributes = attributes;
|
|
556
|
+
|
|
557
|
+
const headers = { 'Content-Type': 'application/json', ...cfg.headers };
|
|
558
|
+
const spin = ui.spinner(`Sending event to ${eventsEndpoint}`);
|
|
559
|
+
try {
|
|
560
|
+
const res = await httpRequest({ endpoint: eventsEndpoint, headers, body: JSON.stringify({ events: [ev] }) });
|
|
561
|
+
if (res.status >= 200 && res.status < 300) {
|
|
562
|
+
spin.stop(`Event accepted (HTTP ${res.status})`);
|
|
563
|
+
if (flags.json) ui.json({ ok: true, status: res.status, endpoint: eventsEndpoint, type });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
spin.fail(`Events ingest returned HTTP ${res.status}`);
|
|
567
|
+
if (res.body) console.log(ui.c.dim(res.body.slice(0, 500)));
|
|
568
|
+
if (flags.json) ui.json({ ok: false, status: res.status, body: res.body });
|
|
569
|
+
process.exit(1);
|
|
570
|
+
} catch (err) {
|
|
571
|
+
spin.fail(`Failed: ${err.message}`);
|
|
572
|
+
if (flags.json) ui.json({ ok: false, error: err.message });
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
module.exports = { testSpan, logSend, eventSend, doctor, env };
|
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]',
|
|
@@ -600,6 +612,27 @@ const COMMANDS = {
|
|
|
600
612
|
},
|
|
601
613
|
defaultSub: 'send',
|
|
602
614
|
},
|
|
615
|
+
event: {
|
|
616
|
+
desc: 'Emit a custom security event to SecureNow (for scripts, testing, non-JS apps)',
|
|
617
|
+
usage: 'securenow event send <type> [--user <id>] [--session <id>] [--ip <ip>] [--attrs k=v,k=v]',
|
|
618
|
+
sub: {
|
|
619
|
+
send: {
|
|
620
|
+
desc: 'Send a single custom event to the /v1/events ingest',
|
|
621
|
+
flags: {
|
|
622
|
+
env: 'Deployment environment for this event (defaults to credentials file)',
|
|
623
|
+
environment: 'Alias for --env',
|
|
624
|
+
user: 'End-user / account id (enduser.id)',
|
|
625
|
+
session: 'Session id (session.id)',
|
|
626
|
+
ip: 'End-user client IP',
|
|
627
|
+
'user-agent': 'End-user agent string',
|
|
628
|
+
level: 'Severity (trace|debug|info|warn|error|fatal)',
|
|
629
|
+
attrs: 'Comma-separated key=value attributes',
|
|
630
|
+
},
|
|
631
|
+
run: (a, f) => require('./cli/diagnostics').eventSend(a, f),
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
defaultSub: 'send',
|
|
635
|
+
},
|
|
603
636
|
'test-span': {
|
|
604
637
|
desc: 'Emit a test span to verify collector connectivity',
|
|
605
638
|
usage: 'securenow test-span [<span-name>] [--env local|production]',
|
|
@@ -689,8 +722,8 @@ function showHelp(commandName) {
|
|
|
689
722
|
'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
|
|
690
723
|
'Investigate': ['ip', 'forensics'],
|
|
691
724
|
'Firewall': ['firewall'],
|
|
692
|
-
'Remediation': ['automation', 'ratelimit', 'blocklist', 'allowlist', 'trusted'],
|
|
693
|
-
'Telemetry': ['log', 'test-span'],
|
|
725
|
+
'Remediation': ['automation', 'ratelimit', 'blocklist', 'revoke', 'allowlist', 'trusted'],
|
|
726
|
+
'Telemetry': ['log', 'event', 'test-span'],
|
|
694
727
|
'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
|
|
695
728
|
'Settings': ['instances', 'config', 'version'],
|
|
696
729
|
};
|
package/events.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface TrackProps {
|
|
2
|
+
/** End-user / account identifier (durable identity for correlation). */
|
|
3
|
+
userId?: string | number;
|
|
4
|
+
/** Session identifier (finer-grained than userId). */
|
|
5
|
+
sessionId?: string | number;
|
|
6
|
+
/** End-user client IP as seen by your app (enriched to geo/ASN server-side). */
|
|
7
|
+
ip?: string;
|
|
8
|
+
/** End-user agent string. */
|
|
9
|
+
userAgent?: string;
|
|
10
|
+
/** Severity hint. Defaults to "info". */
|
|
11
|
+
severity?: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
12
|
+
/** Event time in epoch ms. Defaults to now. */
|
|
13
|
+
ts?: number;
|
|
14
|
+
/** Arbitrary structured attributes (string/number/boolean values). */
|
|
15
|
+
attributes?: Record<string, string | number | boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Emit a custom security event to SecureNow. Fire-and-forget: returns
|
|
20
|
+
* immediately, batches in the background, and never throws into your app.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { track } from "securenow/events";
|
|
24
|
+
* track("auth.login.success", { userId, sessionId, ip, attributes: { method: "magic_link" } });
|
|
25
|
+
*/
|
|
26
|
+
export function track(type: string, props?: TrackProps): void;
|
|
27
|
+
|
|
28
|
+
/** Force a flush of any buffered events. Best-effort; never throws. */
|
|
29
|
+
export function flush(): Promise<unknown>;
|
package/events.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// SecureNow custom events — a tiny, batched, fire-and-forget emitter.
|
|
4
|
+
//
|
|
5
|
+
// const { track } = require('securenow/events');
|
|
6
|
+
// track('auth.login.success', { userId, sessionId, ip, attributes: { method: 'magic_link' } });
|
|
7
|
+
//
|
|
8
|
+
// Design goals:
|
|
9
|
+
// - Never throw into the caller. A SecureNow/network outage must not affect
|
|
10
|
+
// the app's request path. Events are advisory.
|
|
11
|
+
// - Non-blocking: events are buffered and flushed asynchronously in batches.
|
|
12
|
+
// - Bounded memory: the buffer is capped; oldest events drop on overflow.
|
|
13
|
+
// - Reuses the SDK's resolved runtime config (app key + auth headers +
|
|
14
|
+
// endpoint), so there is nothing extra to configure.
|
|
15
|
+
|
|
16
|
+
const appConfig = require('./app-config');
|
|
17
|
+
|
|
18
|
+
const MAX_BUFFER = 1000; // hard cap on queued events
|
|
19
|
+
const MAX_BATCH = 100; // events sent per flush
|
|
20
|
+
const FLUSH_INTERVAL_MS = 2000;
|
|
21
|
+
const POST_TIMEOUT_MS = 5000;
|
|
22
|
+
|
|
23
|
+
let buffer = [];
|
|
24
|
+
let timer = null;
|
|
25
|
+
let target = null;
|
|
26
|
+
let targetResolved = false;
|
|
27
|
+
|
|
28
|
+
function resolveTarget() {
|
|
29
|
+
if (targetResolved) return target;
|
|
30
|
+
targetResolved = true;
|
|
31
|
+
try {
|
|
32
|
+
const endpoints = appConfig.resolveEndpoints();
|
|
33
|
+
const base = String((endpoints && endpoints.endpointBase) || '').replace(/\/$/, '');
|
|
34
|
+
if (!base) {
|
|
35
|
+
target = null;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const headers = Object.assign({ 'Content-Type': 'application/json' }, (endpoints && endpoints.headers) || {});
|
|
39
|
+
// Without an app key + auth we can't attribute events; stay silent.
|
|
40
|
+
const hasAppKey = headers['x-securenow-app-key'] || headers['X-SecureNow-App-Key'];
|
|
41
|
+
if (!hasAppKey) {
|
|
42
|
+
target = null;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
target = { url: `${base}/v1/events`, headers };
|
|
46
|
+
} catch {
|
|
47
|
+
target = null;
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function post(t, payload) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
let parsed;
|
|
55
|
+
let lib;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new (require('url').URL)(t.url);
|
|
58
|
+
lib = parsed.protocol === 'https:' ? require('https') : require('http');
|
|
59
|
+
} catch {
|
|
60
|
+
return resolve(false);
|
|
61
|
+
}
|
|
62
|
+
let body;
|
|
63
|
+
try {
|
|
64
|
+
body = Buffer.from(JSON.stringify(payload));
|
|
65
|
+
} catch {
|
|
66
|
+
return resolve(false);
|
|
67
|
+
}
|
|
68
|
+
const req = lib.request(
|
|
69
|
+
{
|
|
70
|
+
method: 'POST',
|
|
71
|
+
hostname: parsed.hostname,
|
|
72
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
73
|
+
path: parsed.pathname + parsed.search,
|
|
74
|
+
headers: Object.assign({ 'Content-Length': body.length }, t.headers),
|
|
75
|
+
timeout: POST_TIMEOUT_MS,
|
|
76
|
+
},
|
|
77
|
+
(res) => {
|
|
78
|
+
res.on('data', () => {});
|
|
79
|
+
res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 300));
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
req.on('error', () => resolve(false));
|
|
83
|
+
req.on('timeout', () => {
|
|
84
|
+
try { req.destroy(); } catch {}
|
|
85
|
+
resolve(false);
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
req.write(body);
|
|
89
|
+
req.end();
|
|
90
|
+
} catch {
|
|
91
|
+
resolve(false);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scheduleFlush() {
|
|
97
|
+
if (timer) return;
|
|
98
|
+
timer = setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
99
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function flush() {
|
|
103
|
+
if (timer) {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
timer = null;
|
|
106
|
+
}
|
|
107
|
+
if (!buffer.length) return Promise.resolve();
|
|
108
|
+
const t = resolveTarget();
|
|
109
|
+
if (!t) {
|
|
110
|
+
buffer = []; // not configured to emit — drop silently
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
}
|
|
113
|
+
const batch = buffer.splice(0, MAX_BATCH);
|
|
114
|
+
const p = post(t, { events: batch }).catch(() => false);
|
|
115
|
+
if (buffer.length) scheduleFlush();
|
|
116
|
+
return p;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeEvent(type, props) {
|
|
120
|
+
if (!type || typeof type !== 'string') return null;
|
|
121
|
+
const ev = { type, ts: (props && Number(props.ts)) || Date.now() };
|
|
122
|
+
if (props) {
|
|
123
|
+
if (props.userId != null) ev.user_id = String(props.userId);
|
|
124
|
+
if (props.sessionId != null) ev.session_id = String(props.sessionId);
|
|
125
|
+
if (props.ip != null) ev.ip = String(props.ip);
|
|
126
|
+
if (props.userAgent != null) ev.user_agent = String(props.userAgent);
|
|
127
|
+
if (props.severity) ev.severity = String(props.severity);
|
|
128
|
+
if (props.attributes && typeof props.attributes === 'object' && !Array.isArray(props.attributes)) {
|
|
129
|
+
ev.attributes = props.attributes;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return ev;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Emit a custom event. Fire-and-forget — returns immediately, never throws.
|
|
137
|
+
* @param {string} type Event type, e.g. "auth.login.success".
|
|
138
|
+
* @param {object} [props] { userId, sessionId, ip, userAgent, severity, ts, attributes }
|
|
139
|
+
*/
|
|
140
|
+
function track(type, props = {}) {
|
|
141
|
+
try {
|
|
142
|
+
const ev = normalizeEvent(type, props);
|
|
143
|
+
if (!ev) return;
|
|
144
|
+
buffer.push(ev);
|
|
145
|
+
if (buffer.length > MAX_BUFFER) buffer.splice(0, buffer.length - MAX_BUFFER); // drop oldest
|
|
146
|
+
if (buffer.length >= MAX_BATCH) flush();
|
|
147
|
+
else scheduleFlush();
|
|
148
|
+
} catch {
|
|
149
|
+
/* never throw into the app */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Best-effort flush so buffered events aren't lost on a clean shutdown.
|
|
154
|
+
try {
|
|
155
|
+
process.once('beforeExit', () => {
|
|
156
|
+
try { flush(); } catch {}
|
|
157
|
+
});
|
|
158
|
+
} catch {}
|
|
159
|
+
|
|
160
|
+
module.exports = { track, flush };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securenow",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.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",
|
|
@@ -50,6 +50,14 @@
|
|
|
50
50
|
"types": "./tracing.d.ts",
|
|
51
51
|
"default": "./tracing.js"
|
|
52
52
|
},
|
|
53
|
+
"./events": {
|
|
54
|
+
"types": "./events.d.ts",
|
|
55
|
+
"default": "./events.js"
|
|
56
|
+
},
|
|
57
|
+
"./sessions": {
|
|
58
|
+
"types": "./sessions.d.ts",
|
|
59
|
+
"default": "./sessions.js"
|
|
60
|
+
},
|
|
53
61
|
"./console-instrumentation": {
|
|
54
62
|
"default": "./console-instrumentation.js"
|
|
55
63
|
},
|
|
@@ -112,6 +120,10 @@
|
|
|
112
120
|
"register.d.ts",
|
|
113
121
|
"tracing.js",
|
|
114
122
|
"tracing.d.ts",
|
|
123
|
+
"events.js",
|
|
124
|
+
"events.d.ts",
|
|
125
|
+
"sessions.js",
|
|
126
|
+
"sessions.d.ts",
|
|
115
127
|
"console-instrumentation.js",
|
|
116
128
|
"nextjs.js",
|
|
117
129
|
"nextjs.d.ts",
|
package/sessions.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Start background revocation sync (called automatically by guard()). */
|
|
15
|
+
export function start(options?: GuardOptions): void;
|
|
16
|
+
|
|
17
|
+
/** Force one sync now. Best-effort; never throws. */
|
|
18
|
+
export function syncOnce(): Promise<boolean>;
|
|
19
|
+
|
|
20
|
+
/** Is this session/user currently revoked? */
|
|
21
|
+
export function isRevoked(input: { sessionId?: string | null; userId?: string | null }): boolean;
|
|
22
|
+
|
|
23
|
+
/** Express-style middleware that rejects revoked sessions/users. Fail-open. */
|
|
24
|
+
export function guard(options?: GuardOptions): (req: any, res: any, next: any) => void;
|
|
25
|
+
|
|
26
|
+
/** SHA-256 helper (used to hash raw session tokens consistently). */
|
|
27
|
+
export function sha256(value: string): string;
|
package/sessions.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
|
|
18
|
+
let _entries = new Map(); // "type:value" -> expiryMs (0 = never)
|
|
19
|
+
let _etag = null;
|
|
20
|
+
let _started = false;
|
|
21
|
+
let _timer = null;
|
|
22
|
+
let _syncIntervalMs = 30000;
|
|
23
|
+
|
|
24
|
+
function sha256(value) {
|
|
25
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveSyncTarget() {
|
|
29
|
+
try {
|
|
30
|
+
const fw = appConfig.resolveFirewallOptions();
|
|
31
|
+
const apiUrl = String((fw && fw.apiUrl) || '').replace(/\/$/, '');
|
|
32
|
+
const apiKey = fw && fw.apiKey;
|
|
33
|
+
const resolvedApp = appConfig.resolveAll();
|
|
34
|
+
const appKey = resolvedApp && resolvedApp.appKey;
|
|
35
|
+
let env = 'production';
|
|
36
|
+
try { env = appConfig.resolveDeploymentEnvironment() || 'production'; } catch {}
|
|
37
|
+
if (!apiUrl || !apiKey || !appKey) return null;
|
|
38
|
+
const url = `${apiUrl}/api/v1/revocations/sync?app=${encodeURIComponent(appKey)}&env=${encodeURIComponent(env)}`;
|
|
39
|
+
return { url, apiKey, appKey };
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function applyEntries(list) {
|
|
46
|
+
const next = new Map();
|
|
47
|
+
for (const e of list || []) {
|
|
48
|
+
if (!e || !e.type || !e.value) continue;
|
|
49
|
+
const exp = e.expiresAt ? Date.parse(e.expiresAt) : 0;
|
|
50
|
+
next.set(`${e.type}:${e.value}`, Number.isFinite(exp) ? exp : 0);
|
|
51
|
+
}
|
|
52
|
+
_entries = next;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function syncOnce() {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const t = resolveSyncTarget();
|
|
58
|
+
if (!t) return resolve(false);
|
|
59
|
+
let parsed;
|
|
60
|
+
let lib;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new (require('url').URL)(t.url);
|
|
63
|
+
lib = parsed.protocol === 'https:' ? require('https') : require('http');
|
|
64
|
+
} catch {
|
|
65
|
+
return resolve(false);
|
|
66
|
+
}
|
|
67
|
+
const headers = { Authorization: `Bearer ${t.apiKey}`, 'x-securenow-app-key': t.appKey };
|
|
68
|
+
if (_etag) headers['if-none-match'] = _etag;
|
|
69
|
+
const req = lib.request(
|
|
70
|
+
{
|
|
71
|
+
method: 'GET',
|
|
72
|
+
hostname: parsed.hostname,
|
|
73
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
74
|
+
path: parsed.pathname + parsed.search,
|
|
75
|
+
headers,
|
|
76
|
+
timeout: 5000,
|
|
77
|
+
},
|
|
78
|
+
(res) => {
|
|
79
|
+
if (res.statusCode === 304) {
|
|
80
|
+
res.resume();
|
|
81
|
+
return resolve(true);
|
|
82
|
+
}
|
|
83
|
+
const etag = res.headers.etag;
|
|
84
|
+
const chunks = [];
|
|
85
|
+
res.on('data', (c) => chunks.push(c));
|
|
86
|
+
res.on('end', () => {
|
|
87
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
88
|
+
try {
|
|
89
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
90
|
+
applyEntries(body && body.revocations && body.revocations.entries);
|
|
91
|
+
if (etag) _etag = etag;
|
|
92
|
+
} catch {}
|
|
93
|
+
return resolve(true);
|
|
94
|
+
}
|
|
95
|
+
return resolve(false);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
req.on('error', () => resolve(false));
|
|
100
|
+
req.on('timeout', () => {
|
|
101
|
+
try { req.destroy(); } catch {}
|
|
102
|
+
resolve(false);
|
|
103
|
+
});
|
|
104
|
+
req.end();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isExpired(exp) {
|
|
109
|
+
return exp && Date.now() > exp;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Is this session/user currently revoked? Pass whatever you have.
|
|
114
|
+
* @param {{sessionId?: string, userId?: string}} input
|
|
115
|
+
*/
|
|
116
|
+
function isRevoked(input) {
|
|
117
|
+
if (!input) return false;
|
|
118
|
+
const sid = input.sessionId != null ? String(input.sessionId) : null;
|
|
119
|
+
const uid = input.userId != null ? String(input.userId) : null;
|
|
120
|
+
if (sid) {
|
|
121
|
+
const e = _entries.get(`session:${sid}`);
|
|
122
|
+
if (e !== undefined && !isExpired(e)) return true;
|
|
123
|
+
}
|
|
124
|
+
if (uid) {
|
|
125
|
+
const e = _entries.get(`user:${uid}`);
|
|
126
|
+
if (e !== undefined && !isExpired(e)) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function start(options = {}) {
|
|
132
|
+
if (options.syncIntervalMs) _syncIntervalMs = options.syncIntervalMs;
|
|
133
|
+
if (_started) return;
|
|
134
|
+
_started = true;
|
|
135
|
+
syncOnce();
|
|
136
|
+
_timer = setInterval(syncOnce, _syncIntervalMs);
|
|
137
|
+
if (_timer && typeof _timer.unref === 'function') _timer.unref();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Tier-0 auto session detection (zero app code) -------------------------
|
|
141
|
+
const SESSION_COOKIES = [
|
|
142
|
+
'__Secure-next-auth.session-token',
|
|
143
|
+
'__Host-next-auth.session-token',
|
|
144
|
+
'next-auth.session-token',
|
|
145
|
+
'connect.sid',
|
|
146
|
+
'session',
|
|
147
|
+
'sid',
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
function parseCookies(header) {
|
|
151
|
+
const out = {};
|
|
152
|
+
if (!header) return out;
|
|
153
|
+
for (const part of String(header).split(';')) {
|
|
154
|
+
const i = part.indexOf('=');
|
|
155
|
+
if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Mirrors how an auto-adapter emits session.id/enduser.id: hash the raw
|
|
161
|
+
// cookie/token; read the JWT `sub` for the user id.
|
|
162
|
+
function defaultExtract(req) {
|
|
163
|
+
let sessionId = null;
|
|
164
|
+
let userId = null;
|
|
165
|
+
const headers = req.headers || {};
|
|
166
|
+
const auth = headers.authorization || headers.Authorization;
|
|
167
|
+
if (auth && /^Bearer\s+/i.test(auth)) {
|
|
168
|
+
const token = auth.replace(/^Bearer\s+/i, '').trim();
|
|
169
|
+
if (token) {
|
|
170
|
+
sessionId = sha256(token);
|
|
171
|
+
try {
|
|
172
|
+
const seg = token.split('.')[1] || '';
|
|
173
|
+
const payload = JSON.parse(Buffer.from(seg, 'base64').toString('utf8'));
|
|
174
|
+
if (payload && payload.sub) userId = String(payload.sub);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!sessionId && headers.cookie) {
|
|
179
|
+
const cookies = parseCookies(headers.cookie);
|
|
180
|
+
for (const name of SESSION_COOKIES) {
|
|
181
|
+
if (cookies[name]) {
|
|
182
|
+
sessionId = sha256(cookies[name]);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { sessionId, userId };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function defaultOnRevoked(req, res) {
|
|
191
|
+
try {
|
|
192
|
+
const clears = SESSION_COOKIES.map((n) => `${n}=; Path=/; Max-Age=0; HttpOnly`);
|
|
193
|
+
if (typeof res.setHeader === 'function') res.setHeader('Set-Cookie', clears);
|
|
194
|
+
} catch {}
|
|
195
|
+
if (typeof res.status === 'function' && typeof res.json === 'function') {
|
|
196
|
+
return res.status(401).json({ error: 'Session revoked. Please sign in again.', code: 'session_revoked' });
|
|
197
|
+
}
|
|
198
|
+
res.statusCode = 401;
|
|
199
|
+
try { res.end('Session revoked'); } catch {}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Express-style middleware that rejects requests whose session/user is revoked.
|
|
204
|
+
* Starts background sync on first use. Fail-open and never throws.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} [options]
|
|
207
|
+
* @param {(req)=>string} [options.getSessionId] Custom session id extractor.
|
|
208
|
+
* @param {(req)=>string} [options.getUserId] Custom user id extractor.
|
|
209
|
+
* @param {(req,res,next,ctx)=>void} [options.onRevoked] Custom rejection handler.
|
|
210
|
+
* @param {number} [options.syncIntervalMs]
|
|
211
|
+
*/
|
|
212
|
+
function guard(options = {}) {
|
|
213
|
+
start(options);
|
|
214
|
+
const { getSessionId, getUserId } = options;
|
|
215
|
+
const onRevoked = options.onRevoked || defaultOnRevoked;
|
|
216
|
+
return function securenowSessionGuard(req, res, next) {
|
|
217
|
+
try {
|
|
218
|
+
let sessionId = null;
|
|
219
|
+
let userId = null;
|
|
220
|
+
if (getSessionId || getUserId) {
|
|
221
|
+
if (getSessionId) sessionId = getSessionId(req);
|
|
222
|
+
if (getUserId) userId = getUserId(req);
|
|
223
|
+
} else {
|
|
224
|
+
const d = defaultExtract(req);
|
|
225
|
+
sessionId = d.sessionId;
|
|
226
|
+
userId = d.userId;
|
|
227
|
+
}
|
|
228
|
+
if (isRevoked({ sessionId, userId })) {
|
|
229
|
+
return onRevoked(req, res, next, { sessionId, userId });
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
/* fail-open */
|
|
233
|
+
}
|
|
234
|
+
return next();
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { start, syncOnce, isRevoked, guard, sha256 };
|