securenow 8.12.1 → 8.12.3

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/README.md CHANGED
@@ -111,6 +111,8 @@ npx securenow doctor # diagnose config + connectivity
111
111
  npx securenow status # dashboard summary
112
112
  ```
113
113
 
114
+ **Tests** — test runs otherwise report to your real app with the project's `.securenow` credentials. `SECURENOW_DISABLED=1 npm test` turns the SDK off for that process: no traces, logs, events, firewall or session sync, and `guard()` just calls `next()`. This is the only setting read from the environment.
115
+
114
116
  ---
115
117
 
116
118
  ## Connect your coding agent (MCP)
package/cli/automation.js CHANGED
@@ -198,18 +198,20 @@ async function dryRun(args, flags) {
198
198
  ui.keyValue([
199
199
  ['Scanned IPs', String(results.scanned ?? 0)],
200
200
  ['Matched IPs', String(results.matched ?? 0)],
201
+ ...(results.testModeMatched != null ? [['Test-mode matches', `${results.testModeMatched} (preview only: never acted on until the rule is promoted)`]] : []),
201
202
  ['Sample count', String((results.samples || []).length)],
202
203
  ]);
203
204
  if ((results.samples || []).length) {
204
205
  console.log('');
205
206
  const rows = results.samples.map((sample) => [
206
207
  sample.ip,
208
+ ui.truncate([].concat(sample.alertName || []).slice(-1).join(''), 40),
207
209
  ui.truncate((sample.path || []).join(', '), 32),
208
- ui.truncate((sample.attackType || []).join(', '), 28),
209
210
  (sample.environment || []).join(', ') || '-',
210
211
  String(sample.riskScore ?? '-'),
212
+ sample.testMode ? 'test' : 'prod',
211
213
  ]);
212
- ui.table(['IP', 'Paths', 'Attack', 'Env', 'Risk'], rows);
214
+ ui.table(['IP', 'Alert', 'Paths', 'Env', 'Risk', 'Mode'], rows);
213
215
  }
214
216
  console.log('');
215
217
  } catch (err) {
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // `securenow threat-model <category>` — print the copy-paste threat-modeling prompt for a category,
4
+ // fetched from the published set at securenow.ai (the same prompt shown on securenow.ai/threat-modeling).
5
+ // Pipe it straight into your coding agent:
6
+ // claude -p "$(npx securenow threat-model authentication)"
7
+ // or save it: npx securenow threat-model authentication --out prompt.md
8
+ const fs = require('fs');
9
+ const ui = require('./ui');
10
+
11
+ const BASE = process.env.SECURENOW_THREAT_PROMPTS_URL || 'https://securenow.ai/threat-prompts';
12
+
13
+ // Published categories (slugs). Aliases map common shorthands to the slug.
14
+ const CATEGORIES = [
15
+ 'authentication', 'authorization', 'xss-csrf-cors', 'client-side-supply-chain', 'injection',
16
+ 'oauth-sso-integrations', 'admin-support-tools', 'payment-business-logic', 'file-upload-download',
17
+ 'messaging-notifications', 'rate-limits-and-abuse', 'legacy-endpoints', 'api-security', 'graphql-security',
18
+ 'webhooks', 'realtime-websocket-sse', 'serverless-and-edge', 'cloud-infrastructure', 'secrets-and-cloud-iam',
19
+ 'dns-tls-certificates', 'supply-chain-cicd', 'storage-and-logs', 'data-privacy-pii', 'ai-llm-features',
20
+ 'secrets-and-api-key-hygiene',
21
+ ];
22
+ const ALIASES = { auth: 'authentication', authz: 'authorization', oauth: 'oauth-sso-integrations', sso: 'oauth-sso-integrations', api: 'api-security', graphql: 'graphql-security', uploads: 'file-upload-download', upload: 'file-upload-download', xss: 'xss-csrf-cors', csrf: 'xss-csrf-cors', cors: 'xss-csrf-cors', rate: 'rate-limits-and-abuse', 'rate-limits': 'rate-limits-and-abuse', secrets: 'secrets-and-api-key-hygiene', privacy: 'data-privacy-pii', pii: 'data-privacy-pii', ai: 'ai-llm-features', llm: 'ai-llm-features' };
23
+
24
+ function resolveSlug(input) {
25
+ const raw = String(input || '').trim().toLowerCase().replace(/\.md$/, '').replace(/^\d+[-_]/, '');
26
+ if (!raw) return null;
27
+ if (CATEGORIES.includes(raw)) return raw;
28
+ if (ALIASES[raw]) return ALIASES[raw];
29
+ return null;
30
+ }
31
+
32
+ function listCategories() {
33
+ ui.heading('SecureNow threat-modeling prompts');
34
+ console.log(' Copy-paste a prompt into your coding agent, or run it with the SecureNow CLI.\n');
35
+ for (const slug of CATEGORIES) console.log(' ' + slug);
36
+ console.log('\n Print one: securenow threat-model authentication');
37
+ console.log(' Pipe it: claude -p "$(securenow threat-model authentication)"');
38
+ console.log(' Save it: securenow threat-model authentication --out prompt.md');
39
+ console.log(' On the web: securenow.ai/threat-modeling');
40
+ }
41
+
42
+ async function run(args, flags = {}) {
43
+ const category = args[0];
44
+ if (flags.list || (!category && !flags.all)) {
45
+ if (!category && !flags.list) ui.info('Pick a category (or --list to see them all):\n');
46
+ listCategories();
47
+ return;
48
+ }
49
+
50
+ const slug = resolveSlug(category);
51
+ if (!slug) {
52
+ ui.error(`Unknown category "${category}". Run "securenow threat-model --list" to see the ${CATEGORIES.length} categories.`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+
57
+ const url = `${BASE}/${slug}.md`;
58
+ let text;
59
+ try {
60
+ const res = await fetch(url, { headers: { accept: 'text/markdown, text/plain' } });
61
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
62
+ text = await res.text();
63
+ } catch (err) {
64
+ ui.error(`Could not fetch the ${slug} prompt (${err.message}). Open securenow.ai/threat-modeling/${slug} in a browser instead.`);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+
69
+ if (flags.out) {
70
+ fs.writeFileSync(flags.out, text);
71
+ ui.success(`Saved the ${slug} threat-model prompt to ${flags.out} (${text.length.toLocaleString()} chars).`);
72
+ ui.info('Open it with your coding agent at the root of a project that has the securenow SDK installed.');
73
+ return;
74
+ }
75
+
76
+ // Default: raw prompt to stdout so it pipes cleanly into an agent.
77
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
78
+ }
79
+
80
+ module.exports = { run, resolveSlug, CATEGORIES, ALIASES };
package/cli/ui.js CHANGED
@@ -182,10 +182,19 @@ function prompt(question, opts = {}) {
182
182
 
183
183
  return new Promise((resolve) => {
184
184
  const q = opts.secret ? question : ` ${c.cyan('?')} ${question} `;
185
+ let answered = false;
185
186
  rl.question(q, (answer) => {
187
+ answered = true;
186
188
  rl.close();
187
189
  resolve(answer.trim());
188
190
  });
191
+ // stdin closed without an answer (EOF, /dev/null, a coding agent's shell): resolve empty instead of
192
+ // leaving the promise pending until the process silently exits.
193
+ rl.once('close', () => {
194
+ if (answered) return;
195
+ process.stderr.write('\n');
196
+ resolve('');
197
+ });
189
198
 
190
199
  if (opts.secret) {
191
200
  const onData = (char) => {
@@ -202,7 +211,15 @@ function prompt(question, opts = {}) {
202
211
  async function confirm(question, defaultYes = false) {
203
212
  const hint = defaultYes ? 'Y/n' : 'y/N';
204
213
  const answer = await prompt(`${question} ${c.dim(`(${hint})`)}`);
205
- if (!answer) return defaultYes;
214
+ if (!answer) {
215
+ // A script or coding agent has no terminal to answer from: say so and exit non-zero instead of
216
+ // silently cancelling the write with exit code 0.
217
+ if (!defaultYes && !process.stdin.isTTY) {
218
+ error('No confirmation received (non-interactive shell). Re-run with --yes to confirm.');
219
+ process.exitCode = 1;
220
+ }
221
+ return defaultYes;
222
+ }
206
223
  return answer.toLowerCase().startsWith('y');
207
224
  }
208
225
 
package/cli.js CHANGED
@@ -671,6 +671,12 @@ const COMMANDS = {
671
671
  rawArgv: true,
672
672
  run: (rawArgs) => require('./cli/run').run(rawArgs),
673
673
  },
674
+ 'threat-model': {
675
+ desc: 'Print a copy-paste threat-modeling prompt for a category (pipe it into your coding agent)',
676
+ usage: 'securenow threat-model <authentication|authorization|…|--list> [--out prompt.md]',
677
+ flags: { list: 'List all threat-modeling categories', out: 'Write the prompt to a file instead of stdout' },
678
+ run: (a, f) => require('./cli/threatModel').run(a, f),
679
+ },
674
680
  redact: {
675
681
  desc: 'Redact sensitive fields and OTP codes from a JSON payload',
676
682
  usage: "securenow redact '<json>' [--fields f1,f2] [--json]",
@@ -822,7 +828,7 @@ function showHelp(commandName) {
822
828
  'Firewall': ['firewall'],
823
829
  'Remediation': ['automation', 'ratelimit', 'challenge', 'blocklist', 'revoke', 'allowlist', 'trusted'],
824
830
  'Telemetry': ['telemetry', 'log', 'event', 'test-span'],
825
- 'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
831
+ 'Utilities': ['threat-model', 'redact', 'cidr', 'doctor', 'env', 'mcp'],
826
832
  'Settings': ['instances', 'config', 'version'],
827
833
  };
828
834
 
@@ -64,18 +64,7 @@ const SeverityNumber = {
64
64
  * Format arguments into a log message
65
65
  */
66
66
  function formatMessage(args) {
67
- return args
68
- .map((arg) => {
69
- if (typeof arg === 'object' && arg !== null) {
70
- try {
71
- return JSON.stringify(arg);
72
- } catch (e) {
73
- return String(arg);
74
- }
75
- }
76
- return String(arg);
77
- })
78
- .join(' ');
67
+ return require('./log-format').formatLogArgs(args);
79
68
  }
80
69
 
81
70
  const { context, trace } = require('@opentelemetry/api');
package/events.js CHANGED
@@ -139,6 +139,7 @@ function normalizeEvent(type, props) {
139
139
  */
140
140
  function track(type, props = {}) {
141
141
  try {
142
+ if (require('./sdk-disabled').sdkDisabled()) return;
142
143
  const ev = normalizeEvent(type, props);
143
144
  if (!ev) return;
144
145
  buffer.push(ev);
package/firewall-only.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
4
4
  if (require('./internal-thread').inInternalThread()) return;
5
+ // SECURENOW_DISABLED=1 turns the SDK off for this process (test runs).
6
+ if (require('./sdk-disabled').sdkDisabled('firewall')) return;
5
7
 
6
8
  /**
7
9
  * Standalone firewall preload — no OpenTelemetry, no tracing.
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ // Route matching for firewall block, rate-limit and challenge rules.
4
+ //
5
+ // Many servers answer more than one spelling of a path: Express 5 routes case-insensitively and ignores
6
+ // a trailing slash, so POST /API/AUTH/LOGIN/ reaches the /api/auth/login handler. A rule matched only
7
+ // against the raw path let those aliases walk past a route-scoped block. Rules now also match the
8
+ // canonical form (percent-decoded, lower-case, duplicate slashes collapsed; exact rules also ignore a
9
+ // trailing slash). Every caller enforces (block / rate-limit / challenge), so matching more spellings
10
+ // is the safe direction.
11
+
12
+ function normalizePrefixPattern(pattern) {
13
+ const value = String(pattern || '').trim();
14
+ if (!value) return '';
15
+ return value.endsWith('*') ? value.slice(0, -1) : value;
16
+ }
17
+
18
+ function canonicalPath(path, { stripTrailingSlash = false } = {}) {
19
+ let value = String(path || '/');
20
+ try { value = decodeURIComponent(value); } catch (_) { /* keep the raw spelling */ }
21
+ value = value.toLowerCase().replace(/\/{2,}/g, '/');
22
+ if (stripTrailingSlash && value.length > 1) value = value.replace(/\/+$/, '') || '/';
23
+ return value || '/';
24
+ }
25
+
26
+ function rulePathMatches(rule, path) {
27
+ const pattern = String((rule && rule.pathPattern) || '').trim();
28
+ if (!pattern) return true;
29
+ const mode = (rule && rule.pathMatchMode) || 'prefix';
30
+ if (mode === 'regex') {
31
+ try {
32
+ const re = new RegExp(pattern);
33
+ return re.test(path) || re.test(canonicalPath(path, { stripTrailingSlash: true }));
34
+ } catch (_) {
35
+ return false;
36
+ }
37
+ }
38
+ if (mode === 'exact') {
39
+ return path === pattern
40
+ || canonicalPath(path, { stripTrailingSlash: true }) === canonicalPath(pattern, { stripTrailingSlash: true });
41
+ }
42
+ const prefix = normalizePrefixPattern(pattern);
43
+ return path.startsWith(prefix) || canonicalPath(path).startsWith(canonicalPath(prefix));
44
+ }
45
+
46
+ module.exports = { canonicalPath, normalizePrefixPattern, rulePathMatches };
package/firewall.js CHANGED
@@ -911,16 +911,8 @@ function ruleIpMatches(rule, ip) {
911
911
  }
912
912
  }
913
913
 
914
- function rulePathMatches(rule, path) {
915
- const pattern = String(rule.pathPattern || '').trim();
916
- if (!pattern) return true;
917
- const mode = rule.pathMatchMode || 'prefix';
918
- if (mode === 'exact') return path === pattern;
919
- if (mode === 'regex') {
920
- try { return new RegExp(pattern).test(path); } catch (_) { return false; }
921
- }
922
- return path.startsWith(normalizePrefixPattern(pattern));
923
- }
914
+ // Matches the raw path and its canonical aliases (case, trailing/duplicate slashes, percent-encoding).
915
+ const { rulePathMatches } = require('./firewall-paths');
924
916
 
925
917
  function rateLimitKey(rule, ip) {
926
918
  const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
package/log-format.js ADDED
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ // Body of an exported console log record, matching what the terminal shows.
4
+ // - A first string argument with printf-style placeholders (%s %d %i %f %j %o %O %c %%) is formatted
5
+ // like console.log does (util.format), so "%d users" becomes "3 users" instead of "%d users 3".
6
+ // - Errors keep their stack (JSON.stringify(new Error('x')) is "{}").
7
+ // - Other objects stay JSON, as before, so structured logs remain parseable.
8
+ const util = require('util');
9
+
10
+ const PLACEHOLDER = /%[sdifjoOc%]/;
11
+
12
+ function renderArg(arg) {
13
+ if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`;
14
+ if (typeof arg === 'object' && arg !== null) {
15
+ try { return JSON.stringify(arg); } catch (_) { return util.inspect(arg, { depth: 4, breakLength: Infinity }); }
16
+ }
17
+ return String(arg);
18
+ }
19
+
20
+ function formatLogArgs(args) {
21
+ if (!Array.isArray(args) || args.length === 0) return '';
22
+ if (typeof args[0] === 'string' && PLACEHOLDER.test(args[0])) {
23
+ try { return util.format(...args); } catch (_) { /* fall through */ }
24
+ }
25
+ return args.map(renderArg).join(' ');
26
+ }
27
+
28
+ module.exports = { formatLogArgs };
package/nextjs.js CHANGED
@@ -221,6 +221,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
221
221
  * @param {boolean} options.noUuid - Don't append UUID to service name
222
222
  */
223
223
  function registerSecureNow(options = {}) {
224
+ if (require('./sdk-disabled').sdkDisabled('nextjs')) return;
224
225
  // Only register once
225
226
  if (isRegistered) {
226
227
  console.log('[securenow] Already registered, skipping...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.12.1",
3
+ "version": "8.12.3",
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",
@@ -10,7 +10,7 @@
10
10
  "securenow-mcp": "mcp/server.js"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-internal-thread.js && node test-package-contents.js"
13
+ "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-log-format.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-package-contents.js"
14
14
  },
15
15
  "keywords": [
16
16
  "opentelemetry",
@@ -162,6 +162,9 @@
162
162
  "legacy-semconv.js",
163
163
  "otp-redaction.js",
164
164
  "header-redaction.js",
165
+ "firewall-paths.js",
166
+ "log-format.js",
167
+ "sdk-disabled.js",
165
168
  "rate-limits.js",
166
169
  "rate-limits.d.ts",
167
170
  "firewall-only.js",
package/register.js CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  // Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
10
10
  if (require('./internal-thread').inInternalThread()) return;
11
+ // SECURENOW_DISABLED=1 turns the SDK off for this process (test runs).
12
+ if (require('./sdk-disabled').sdkDisabled('register')) return;
11
13
 
12
14
  // 1. Auto-register the ESM loader hook so customers never need --import
13
15
  (() => {
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ // Kill switch: SECURENOW_DISABLED=1 (or true / yes / on) turns the SDK off for one process. Tracing, log
4
+ // export, the firewall, track() events and the sessions guard's sync and session.seen all stay idle, and
5
+ // the guard just calls next(). Meant for unit and integration test runs, which otherwise report to the
6
+ // real app with the project's .securenow credentials.
7
+ let _announced = false;
8
+
9
+ function sdkDisabled(component) {
10
+ const on = /^(1|true|yes|on)$/i.test(String(process.env.SECURENOW_DISABLED || '').trim());
11
+ if (on && component && !_announced) {
12
+ _announced = true;
13
+ try { console.log(`[securenow] disabled by SECURENOW_DISABLED (${component} not started)`); } catch (_) { /* ignore */ }
14
+ }
15
+ return on;
16
+ }
17
+
18
+ module.exports = { sdkDisabled };
package/sessions.js CHANGED
@@ -130,6 +130,7 @@ function isRevoked(input) {
130
130
  }
131
131
 
132
132
  function start(options = {}) {
133
+ if (require('./sdk-disabled').sdkDisabled()) return;
133
134
  if (options.syncIntervalMs) _syncIntervalMs = options.syncIntervalMs;
134
135
  if (_started) return;
135
136
  _started = true;
@@ -277,6 +278,7 @@ function defaultOnRevoked(req, res) {
277
278
  * @param {number} [options.syncIntervalMs]
278
279
  */
279
280
  function guard(options = {}) {
281
+ if (require('./sdk-disabled').sdkDisabled('sessions guard')) return function securenowSessionGuardDisabled(req, res, next) { return next(); };
280
282
  start(options);
281
283
  if (options.captureWindowMs) _captureWindowMs = options.captureWindowMs;
282
284
  const { getSessionId, getUserId } = options;
package/tracing.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
4
4
  if (require('./internal-thread').inInternalThread()) return;
5
+ // SECURENOW_DISABLED=1 turns the SDK off for this process (test runs).
6
+ if (require('./sdk-disabled').sdkDisabled('tracing')) return;
5
7
 
6
8
  /**
7
9
  * Preload with: node --require securenow/register app.js
@@ -665,7 +667,7 @@ if (loggingEnabled) {
665
667
  _logger.emit({
666
668
  severityNumber: sn,
667
669
  severityText: st,
668
- body: args.map(a => (typeof a === 'object' && a !== null) ? JSON.stringify(a) : String(a)).join(' '),
670
+ body: require('./log-format').formatLogArgs(args),
669
671
  attributes: { 'log.source': 'console', 'log.method': st.toLowerCase() },
670
672
  ...(spanCtx && { context: activeCtx }),
671
673
  });