securenow 8.3.0 → 8.5.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
@@ -263,9 +263,13 @@ npx securenow notifications read-all
263
263
 
264
264
  ```js
265
265
  const securenow = require('securenow/sessions');
266
- app.use(securenow.guard()); // auto-detects NextAuth/connect.sid/JWT sessions; rejects revoked ones (401)
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());
267
269
  // ...or check manually:
268
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());
269
273
  ```
270
274
 
271
275
  Revoke from the CLI (the SDK enforces within ~seconds, outbound-only, fail-open):
package/SKILL-CLI.md CHANGED
@@ -255,23 +255,37 @@ Write tools still require `confirm:true` plus a reason. False positives should s
255
255
  ```bash
256
256
  securenow alerts # list alert rules (default)
257
257
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
258
- securenow alerts rules create \ # NEW: create a custom detection rule from inline SQL
258
+ securenow alerts rules validate \ # dry-run a candidate query BEFORE creating any rule
259
+ --sql @rule.sql --app <key> # no rule id needed — auto-hosts on an existing (system) rule
260
+ securenow alerts rules create \ # create a custom detection rule from inline SQL
259
261
  --name "Auth: magic-link brute force" \
260
262
  --sql @rule.sql \ # SQL, @file, or - for stdin; scope apps with __USER_APP_KEYS__
261
263
  --apps key1,key2 \ # or --applications-all
262
264
  --severity high --schedule "*/15 * * * *" \
263
265
  --nlp "single IP flooding /api/auth/signin or /api/auth/callback"
266
+ # create auto dry-runs + rolls back (deletes) on query failure; --no-validate to skip
264
267
  securenow alerts rules show <id> # one rule; JSON: --json
268
+ securenow alerts rules set-sql <id> --sql @new.sql # replace a CUSTOM rule's SQL in place (alias: update <id> --sql)
269
+ securenow alerts rules delete <id> --yes # delete a custom rule (system rules: disable instead)
265
270
  securenow alerts rules update <id> --applications-all # all current & future apps
266
271
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
267
- securenow alerts rules test <id> --mode dry_run --wait # validate a rule query
268
- securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait
272
+ securenow alerts rules test <id> --mode dry_run --wait # validate an existing rule's query
273
+ securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait # candidate test against a specific host rule
269
274
  securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
270
275
  securenow alerts rules exclusions <id> list # embedded rule exclusions
271
276
  securenow alerts channels # list alert channels (Slack, email, etc.)
272
277
  securenow alerts history [--limit N] # past triggered alerts
273
278
  ```
274
279
 
280
+ **Authoring loop that won't strand a broken rule:** `validate --sql @rule.sql --app <key>`
281
+ (dry-run before anything exists) → `create …` (which itself dry-runs and auto-rolls-back on
282
+ failure) → if you later need to fix it, `set-sql <id> --sql @rule.sql` (custom rules) or
283
+ `delete <id>`. Detection `.sql` files may start with `--`/`/* */` comments. Custom-rule SQL is
284
+ editable; **system**-rule SQL changes go through `tune-query … --apply-globally`. Pick the
285
+ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) use
286
+ `resources_string['service.name'] IN (__USER_APP_KEYS__)`, traces
287
+ (`signoz_traces.distributed_signoz_index_v3`) use `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``.
288
+
275
289
  `alerts rules create` flags: `--name` (required); one of `--sql <sql|@file|->` or `--query-mapping-id <id>`; one of `--apps k1,k2` or `--applications-all`; optional `--description`, `--nlp` (plain-English intent), `--category`, `--severity critical|high|medium|low`, `--schedule <cron>` (default `*/15 * * * *`), `--throttle-minutes N` / `--no-throttle`, `--execution-mode scheduled|instant|hybrid`, `--channel id1,id2` (defaults to your in-app SecureNow channel). Detection SQL must scope app keys via the `__USER_APP_KEYS__` placeholder and select an `ip` column for per-IP aggregation/remediation. Requires `alerts:write`.
276
290
 
277
291
  MCP parity for noisy alert-rule reviews:
package/cli/security.js CHANGED
@@ -45,10 +45,16 @@ async function alertRulesRoute(args, flags) {
45
45
  if (sub === 'update') {
46
46
  return alertRuleUpdate(args.slice(1), flags);
47
47
  }
48
+ if (sub === 'set-sql' || sub === 'edit-sql') {
49
+ return alertRuleSetSql(args.slice(1), flags);
50
+ }
51
+ if (sub === 'delete' || sub === 'rm' || sub === 'remove') {
52
+ return alertRuleDelete(args.slice(1), flags);
53
+ }
48
54
  if (sub === 'test') {
49
55
  return alertRuleTest(args.slice(1), flags);
50
56
  }
51
- if (sub === 'dry-run-query' || sub === 'candidate-test') {
57
+ if (sub === 'dry-run-query' || sub === 'candidate-test' || sub === 'validate') {
52
58
  return alertRuleCandidateTest(args.slice(1), flags);
53
59
  }
54
60
  if (sub === 'tune-query' || sub === 'query-update') {
@@ -156,14 +162,35 @@ async function alertRuleCreate(args, flags) {
156
162
  }
157
163
 
158
164
  const s = ui.spinner('Creating alert rule');
165
+ let createdId = null;
159
166
  try {
160
167
  const data = await api.post('/alert-rules', body);
161
168
  const r = data.alertRule || data;
169
+ createdId = r._id || r.id || null;
162
170
  s.stop('Alert rule created');
171
+
172
+ // Transactional safety: `create` only runs scope/safety validation, not a real
173
+ // query execution — so a query that is valid-but-wrong (e.g. an unknown column)
174
+ // is accepted and would silently never fire. Custom-rule SQL also can't be
175
+ // patched via the API, so a broken rule would be stuck. We therefore dry-run the
176
+ // new scheduled rule and roll it back (delete) on a hard query failure, unless
177
+ // --no-validate is passed. A "complete" dry-run with 0 rows is healthy, not a failure.
178
+ const scheduled = r.executionMode !== 'instant' && r.schedule?.enabled !== false;
179
+ if (createdId && scheduled && !flags['no-validate']) {
180
+ const v = await validateRuleById(createdId, flags);
181
+ if (v && v.status === 'failed') {
182
+ const rolledBack = await api.delete(`/alert-rules/${createdId}`).then(() => true).catch(() => false);
183
+ ui.error(`Rule query failed validation and was ${rolledBack ? 'rolled back (deleted)' : 'left in place (auto-delete failed — remove it manually)'}:`);
184
+ ui.error(` ${v.error || 'unknown query error'}`);
185
+ ui.info('Fix the SQL and re-create, or validate first: securenow alerts rules validate --sql @rule.sql --app <key>');
186
+ process.exit(1);
187
+ }
188
+ }
189
+
163
190
  if (flags.json) { ui.json(data); return; }
164
191
  console.log('');
165
192
  ui.keyValue([
166
- ['ID', r._id || r.id || '-'],
193
+ ['ID', createdId || '-'],
167
194
  ['Name', r.name || '-'],
168
195
  ['Status', r.status || '-'],
169
196
  ['Mode', r.executionMode || 'scheduled'],
@@ -172,9 +199,10 @@ async function alertRuleCreate(args, flags) {
172
199
  ['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '-')],
173
200
  ['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
174
201
  ['Query', r.queryMappingId?.name || r.queryMappingId || '-'],
202
+ ['Validation', flags['no-validate'] ? 'skipped' : 'dry-run clean'],
175
203
  ]);
176
204
  console.log('');
177
- ui.success(`Created. View it with: securenow alerts rules show ${r._id || r.id}`);
205
+ ui.success(`Created. View it with: securenow alerts rules show ${createdId}`);
178
206
  } catch (err) {
179
207
  s.fail('Failed to create alert rule');
180
208
  throw err;
@@ -223,6 +251,11 @@ async function alertRuleShow(args, flags) {
223
251
 
224
252
  async function alertRuleUpdate(args, flags) {
225
253
  requireAuth();
254
+ // `update <id> --sql ...` is an alias for set-sql so SQL edits and scope edits
255
+ // share one verb. set-sql handles its own validation/feedback.
256
+ if (flags.sql || flags.query || flags.file) {
257
+ return alertRuleSetSql(args, flags);
258
+ }
226
259
  const id = args[0];
227
260
  if (!id) {
228
261
  ui.error('Usage: securenow alerts rules update <rule-id> (--applications-all | --apps <k1,k2>)');
@@ -280,6 +313,88 @@ async function alertRuleUpdate(args, flags) {
280
313
  }
281
314
  }
282
315
 
316
+ async function alertRuleSetSql(args, flags) {
317
+ requireAuth();
318
+ const id = args[0];
319
+ const sqlQuery = readSqlArg(flags);
320
+ if (!id || !sqlQuery) {
321
+ ui.error('Usage: securenow alerts rules set-sql <rule-id> --sql <sql|@file|-> [--nlp "intent"] [--no-validate]');
322
+ process.exit(1);
323
+ }
324
+ const body = { sqlQuery };
325
+ if (flags.nlp || flags.text) body.nlpQuery = flags.nlp || flags.text;
326
+ if (flags.category) body.category = flags.category;
327
+
328
+ const s = ui.spinner('Updating rule SQL');
329
+ try {
330
+ const data = await api.put(`/alert-rules/${id}`, body);
331
+ s.stop('Rule SQL updated');
332
+ const ok = flags['no-validate'] ? null : await validateRuleById(id, flags);
333
+ if (ok && ok.status === 'failed') {
334
+ ui.warn(`Saved, but the new SQL failed a dry-run: ${ok.error || 'unknown error'}`);
335
+ ui.info('Fix the SQL and run set-sql again, or delete the rule with: securenow alerts rules delete ' + id);
336
+ } else if (ok) {
337
+ ui.success('Saved and dry-run clean.');
338
+ }
339
+ if (flags.json) ui.json(data);
340
+ } catch (err) {
341
+ s.fail('Failed to update rule SQL');
342
+ throw err;
343
+ }
344
+ }
345
+
346
+ async function alertRuleDelete(args, flags) {
347
+ requireAuth();
348
+ const id = args[0];
349
+ if (!id) {
350
+ ui.error('Usage: securenow alerts rules delete <rule-id> [--yes]');
351
+ process.exit(1);
352
+ }
353
+ if (!flags.force && !flags.yes) {
354
+ const ok = await ui.confirm(`Delete alert rule ${id}? This cannot be undone (system rules can only be disabled).`);
355
+ if (!ok) { ui.info('Cancelled'); return; }
356
+ }
357
+ const s = ui.spinner('Deleting alert rule');
358
+ try {
359
+ const data = await api.delete(`/alert-rules/${id}`);
360
+ s.stop('Alert rule deleted');
361
+ if (flags.json) { ui.json(data); return; }
362
+ ui.success(`Deleted alert rule ${id}`);
363
+ } catch (err) {
364
+ s.fail('Failed to delete alert rule');
365
+ throw err;
366
+ }
367
+ }
368
+
369
+ // Run a dry-run test of an existing rule and return { status, error, resultCount }.
370
+ // Used to validate a rule after create/set-sql. Never throws — returns null on error.
371
+ async function validateRuleById(id, flags = {}) {
372
+ try {
373
+ let data = await api.post(`/alert-rules/${id}/test`, { mode: 'dry_run' });
374
+ if (data.testId) {
375
+ for (let i = 0; i < 40; i++) {
376
+ await new Promise((resolve) => setTimeout(resolve, 2000));
377
+ data = await api.get(`/alert-rules/${id}/test/${data.testId}`);
378
+ if (['complete', 'failed'].includes(data.status)) break;
379
+ }
380
+ }
381
+ return { status: data.status, error: data.error, resultCount: data.resultCount };
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ // Pick an existing rule id to host a candidate dry-run (the test endpoint needs a
388
+ // rule to attach the candidate SQL to). Prefer a system rule (always present on
389
+ // provisioned accounts and never mutated by a dry-run).
390
+ async function resolveHostRuleId() {
391
+ const data = await api.get('/alert-rules');
392
+ const rules = data.alertRules || data.rules || [];
393
+ if (!rules.length) return null;
394
+ const sys = rules.find((r) => r.isSystem);
395
+ return (sys || rules[0])._id || (sys || rules[0]).id || null;
396
+ }
397
+
283
398
  function readSqlArg(flags) {
284
399
  const raw = flags.sql || flags.query || flags.file;
285
400
  if (!raw) return null;
@@ -331,12 +446,24 @@ async function alertRuleTest(args, flags) {
331
446
 
332
447
  async function alertRuleCandidateTest(args, flags) {
333
448
  requireAuth();
334
- const id = args[0];
449
+ let id = args[0];
335
450
  const candidateSqlQuery = readSqlArg(flags);
336
- if (!id || !candidateSqlQuery) {
337
- ui.error('Usage: securenow alerts rules dry-run-query <rule-id> --sql <sql|@file|-> [--app <key>] [--wait]');
451
+ if (!candidateSqlQuery) {
452
+ ui.error('Usage: securenow alerts rules validate --sql <sql|@file|-> [--app <key>] (or dry-run-query <rule-id> --sql ...)');
338
453
  process.exit(1);
339
454
  }
455
+ // The test endpoint attaches the candidate SQL to an existing rule. When no
456
+ // rule id is given, auto-pick a host so a candidate can be validated before any
457
+ // rule exists for it (every provisioned account has system rules to host on).
458
+ if (!id) {
459
+ id = await resolveHostRuleId();
460
+ if (!id) {
461
+ ui.error('No existing rule to host the dry-run. Create one rule first, or pass <rule-id>.');
462
+ process.exit(1);
463
+ }
464
+ }
465
+ // `validate` is meant to return a verdict, so wait for the result by default.
466
+ const wait = flags.wait || flags['no-wait'] !== true;
340
467
 
341
468
  const body = { mode: 'dry_run', candidateSqlQuery };
342
469
  if (flags.app) body.applicationKey = flags.app;
@@ -344,7 +471,7 @@ async function alertRuleCandidateTest(args, flags) {
344
471
  const s = ui.spinner('Starting candidate SQL dry-run');
345
472
  try {
346
473
  let data = await api.post(`/alert-rules/${id}/test`, body);
347
- if (flags.wait && data.testId) {
474
+ if (wait && data.testId) {
348
475
  s.update('Waiting for candidate SQL dry-run results');
349
476
  for (let i = 0; i < 40; i++) {
350
477
  await new Promise((resolve) => setTimeout(resolve, 2000));
package/cli.js CHANGED
@@ -255,7 +255,8 @@ const COMMANDS = {
255
255
  usage: 'securenow alerts <subcommand> [options]',
256
256
  sub: {
257
257
  rules: {
258
- desc: 'Create, list, show, update, test, or tune alert rules',
258
+ desc: 'Create, list, show, update, set-sql, validate, delete, test, or tune alert rules',
259
+ usage: 'securenow alerts rules <list|create|show|update|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions> [options]',
259
260
  flags: {
260
261
  json: 'Output as JSON',
261
262
  name: 'With create: rule name',
@@ -276,9 +277,10 @@ const COMMANDS = {
276
277
  app: 'Application key for rule tests',
277
278
  mode: 'Rule test mode: dry_run or live',
278
279
  wait: 'Wait for rule test completion',
279
- sql: 'Detection/candidate/replacement SQL, @file, or - for stdin',
280
+ sql: 'Detection/candidate/replacement SQL, @file, or - for stdin (create, set-sql, update, validate, dry-run-query, tune-query)',
280
281
  query: 'Alias for --sql',
281
282
  file: 'Read SQL from a file',
283
+ 'no-validate': 'With create/set-sql: skip the post-write dry-run + rollback',
282
284
  reason: 'Audit reason for a write',
283
285
  'apply-globally': 'Required for system query tuning',
284
286
  'reactivate-paused': 'Reactivate paused system copies after tuning',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.3.0",
3
+ "version": "8.5.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",
package/sessions.d.ts CHANGED
@@ -9,6 +9,10 @@ export interface GuardOptions {
9
9
  onRevoked?: (req: any, res: any, next: any, ctx: { sessionId: string | null; userId: string | null }) => void;
10
10
  /** Background sync interval in ms (default 30000). */
11
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;
12
16
  }
13
17
 
14
18
  /** Start background revocation sync (called automatically by guard()). */
@@ -20,8 +24,14 @@ export function syncOnce(): Promise<boolean>;
20
24
  /** Is this session/user currently revoked? */
21
25
  export function isRevoked(input: { sessionId?: string | null; userId?: string | null }): boolean;
22
26
 
23
- /** Express-style middleware that rejects revoked sessions/users. Fail-open. */
27
+ /** Express-style middleware: emits session.seen (detection) AND rejects revoked sessions. Fail-open. */
24
28
  export function guard(options?: GuardOptions): (req: any, res: any, next: any) => void;
25
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
+
26
36
  /** SHA-256 helper (used to hash raw session tokens consistently). */
27
37
  export function sha256(value: string): string;
package/sessions.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const crypto = require('crypto');
16
16
  const appConfig = require('./app-config');
17
+ const events = require('./events');
17
18
 
18
19
  let _entries = new Map(); // "type:value" -> expiryMs (0 = never)
19
20
  let _etag = null;
@@ -187,6 +188,72 @@ function defaultExtract(req) {
187
188
  return { sessionId, userId };
188
189
  }
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
+
190
257
  function defaultOnRevoked(req, res) {
191
258
  try {
192
259
  const clears = SESSION_COOKIES.map((n) => `${n}=; Path=/; Max-Age=0; HttpOnly`);
@@ -211,20 +278,16 @@ function defaultOnRevoked(req, res) {
211
278
  */
212
279
  function guard(options = {}) {
213
280
  start(options);
281
+ if (options.captureWindowMs) _captureWindowMs = options.captureWindowMs;
214
282
  const { getSessionId, getUserId } = options;
215
283
  const onRevoked = options.onRevoked || defaultOnRevoked;
284
+ const doCapture = options.capture !== false; // emit session.seen by default
216
285
  return function securenowSessionGuard(req, res, next) {
217
286
  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
- }
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.
228
291
  if (isRevoked({ sessionId, userId })) {
229
292
  return onRevoked(req, res, next, { sessionId, userId });
230
293
  }
@@ -235,4 +298,4 @@ function guard(options = {}) {
235
298
  };
236
299
  }
237
300
 
238
- module.exports = { start, syncOnce, isRevoked, guard, sha256 };
301
+ module.exports = { start, syncOnce, isRevoked, guard, capture, resolveClientIp, sha256 };