securenow 8.1.0 → 8.2.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,32 @@ npx securenow notifications read-all
259
259
 
260
260
  ### Alerting
261
261
 
262
+ ### Emit custom security events (new in 8.2)
263
+
264
+ ```js
265
+ const { track } = require('securenow/events');
266
+
267
+ // Fire-and-forget — batched, async, never throws into your app.
268
+ track('auth.login.success', {
269
+ userId, // enduser.id (durable identity for correlation)
270
+ sessionId, // session.id
271
+ ip, // end-user IP (enriched to geo/ASN server-side)
272
+ attributes: { method: 'magic_link', new_device: true },
273
+ });
274
+ ```
275
+
276
+ 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`:
277
+
278
+ ```
279
+ POST https://<your-ingest-host>/v1/events
280
+ Authorization: Bearer snk_live_...
281
+ X-SecureNow-App-Key: <app-uuid>
282
+
283
+ { "events": [ { "type": "auth.login.success", "user_id": "u_1", "session_id": "s_1", "ip": "1.2.3.4" } ] }
284
+ ```
285
+
286
+ CLI: `npx securenow event send auth.login.failure --user u_1 --ip 1.2.3.4 --attrs reason=bad_token`
287
+
262
288
  ```bash
263
289
  # Create a custom detection rule from your own SQL (new in 8.1)
264
290
  npx securenow alerts rules create \
@@ -527,4 +527,51 @@ async function doctor(_args, flags) {
527
527
  process.exit(ok ? 0 : 1);
528
528
  }
529
529
 
530
- module.exports = { testSpan, logSend, doctor, env };
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.js CHANGED
@@ -600,6 +600,27 @@ const COMMANDS = {
600
600
  },
601
601
  defaultSub: 'send',
602
602
  },
603
+ event: {
604
+ desc: 'Emit a custom security event to SecureNow (for scripts, testing, non-JS apps)',
605
+ usage: 'securenow event send <type> [--user <id>] [--session <id>] [--ip <ip>] [--attrs k=v,k=v]',
606
+ sub: {
607
+ send: {
608
+ desc: 'Send a single custom event to the /v1/events ingest',
609
+ flags: {
610
+ env: 'Deployment environment for this event (defaults to credentials file)',
611
+ environment: 'Alias for --env',
612
+ user: 'End-user / account id (enduser.id)',
613
+ session: 'Session id (session.id)',
614
+ ip: 'End-user client IP',
615
+ 'user-agent': 'End-user agent string',
616
+ level: 'Severity (trace|debug|info|warn|error|fatal)',
617
+ attrs: 'Comma-separated key=value attributes',
618
+ },
619
+ run: (a, f) => require('./cli/diagnostics').eventSend(a, f),
620
+ },
621
+ },
622
+ defaultSub: 'send',
623
+ },
603
624
  'test-span': {
604
625
  desc: 'Emit a test span to verify collector connectivity',
605
626
  usage: 'securenow test-span [<span-name>] [--env local|production]',
@@ -690,7 +711,7 @@ function showHelp(commandName) {
690
711
  'Investigate': ['ip', 'forensics'],
691
712
  'Firewall': ['firewall'],
692
713
  'Remediation': ['automation', 'ratelimit', 'blocklist', 'allowlist', 'trusted'],
693
- 'Telemetry': ['log', 'test-span'],
714
+ 'Telemetry': ['log', 'event', 'test-span'],
694
715
  'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
695
716
  'Settings': ['instances', 'config', 'version'],
696
717
  };
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.1.0",
3
+ "version": "8.2.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,10 @@
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
+ },
53
57
  "./console-instrumentation": {
54
58
  "default": "./console-instrumentation.js"
55
59
  },
@@ -112,6 +116,8 @@
112
116
  "register.d.ts",
113
117
  "tracing.js",
114
118
  "tracing.d.ts",
119
+ "events.js",
120
+ "events.d.ts",
115
121
  "console-instrumentation.js",
116
122
  "nextjs.js",
117
123
  "nextjs.d.ts",