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 +5 -1
- package/SKILL-CLI.md +17 -3
- package/cli/security.js +134 -7
- package/cli.js +4 -2
- package/package.json +1 -1
- package/sessions.d.ts +11 -1
- package/sessions.js +74 -11
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
|
-
|
|
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
|
|
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
|
|
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',
|
|
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 ${
|
|
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
|
-
|
|
449
|
+
let id = args[0];
|
|
335
450
|
const candidateSqlQuery = readSqlArg(flags);
|
|
336
|
-
if (!
|
|
337
|
-
ui.error('Usage: securenow alerts rules
|
|
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 (
|
|
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
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
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 };
|