securenow 8.12.4 → 8.14.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 +2 -0
- package/README.md +21 -5
- package/SKILL-API.md +3 -1
- package/SKILL-CLI.md +2 -0
- package/app-config.js +4 -0
- package/cli/firewall.js +47 -1
- package/cli/monitor.js +6 -5
- package/cli/rateLimits.js +3 -2
- package/cli.js +2 -1
- package/crash-capture.js +125 -0
- package/firewall.js +121 -10
- package/mcp/catalog.js +434 -25
- package/mcp/server.js +21 -13
- package/nextjs.js +9 -13
- package/package.json +4 -2
- package/rate-limit-store.js +175 -0
- package/tracing.js +19 -25
package/NPM_README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow - Complete OpenTelemetry Observability for Node.js
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
OpenTelemetry instrumentation library for Node.js, Next.js, and Nuxt applications. Send distributed traces and logs to any OTLP-compatible observability backend.
|
|
4
6
|
|
|
5
7
|
**Features:**
|
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# SecureNow
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
**Production security for AI-native development teams.**
|
|
4
6
|
|
|
5
7
|
Your coding agent knows your code. SecureNow shows it what's happening in production. One Node.js package, CLI, and MCP server connect Claude Code, Codex, and Cursor to your live application security — attacks, routes, users, traces, IP intelligence, and response controls.
|
|
6
8
|
|
|
7
|
-
Your agent already sees the repository, the dependencies, the auth implementation, the API handlers, the database queries. SecureNow adds the half it can't see: live requests, routes, users and sessions, attack patterns, IP intelligence, traces and logs, security history, and block / rate-limit controls —
|
|
9
|
+
Your agent already sees the repository, the dependencies, the auth implementation, the API handlers, the database queries. SecureNow adds the half it can't see: live requests, routes, users and sessions, attack patterns, IP intelligence, traces and logs, security history, and block / rate-limit controls — every write confirmed by you and recorded.
|
|
8
10
|
|
|
9
11
|
**Find it in code. Watch it in production.**
|
|
10
12
|
|
|
@@ -31,7 +33,7 @@ Agent: Apply the recommended blocks?
|
|
|
31
33
|
You: Yes.
|
|
32
34
|
```
|
|
33
35
|
|
|
34
|
-
Detection and investigation are automatic.
|
|
36
|
+
Detection and investigation are automatic. Your agent proposes actions and asks before it acts: every write needs an explicit `confirm: true` and a reason, applies to your running apps in about 10 seconds, and is reversible (unblock keeps the history, revocations can be restored, rate limits expire). Some automated AI blocks wait in an approval queue (`securenow human`).
|
|
35
37
|
|
|
36
38
|
---
|
|
37
39
|
|
|
@@ -94,7 +96,7 @@ export default defineNuxtConfig({
|
|
|
94
96
|
});
|
|
95
97
|
```
|
|
96
98
|
|
|
97
|
-
**What's captured automatically:** HTTP spans (Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt, raw `http`), database spans (Postgres, MySQL, MongoDB, Redis), `console.*` as logs with trace correlation, request bodies with auto-redaction, multipart metadata (never file content), plus the firewall. Tune everything in `.securenow/runtime.json` under `config`.
|
|
99
|
+
**What's captured automatically:** HTTP spans (Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt, raw `http`), database spans (Postgres, MySQL, MongoDB, Redis), `console.*` as logs with trace correlation, crashes (uncaught exceptions and unhandled rejections) as `FATAL` logs with their stack, request bodies with auto-redaction, multipart metadata (never file content), plus the firewall. Tune everything in `.securenow/runtime.json` under `config`.
|
|
98
100
|
|
|
99
101
|
**Production** — same file structure, no env vars. Generate a tokenless runtime credentials file and mount it as a deployment secret:
|
|
100
102
|
|
|
@@ -174,11 +176,25 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
|
|
|
174
176
|
| `securenow human block <row> --yes --reason "..."` | Approve a proposed block |
|
|
175
177
|
| `securenow blocklist add <ip> [--route /admin*] [--mode prefix] [--duration 24h] [--reason "..."]` | Block an IP globally or per-route |
|
|
176
178
|
| `securenow blocklist unblock <id> --reason "..."` | Stop enforcement, keep the history |
|
|
177
|
-
| `securenow ratelimit from-text "
|
|
179
|
+
| `securenow ratelimit from-text "5 per minute per IP on POST /api/login for 2h" --yes` | Rate-limit rule from natural language |
|
|
180
|
+
| `securenow ratelimit add --route /api/ --limit 100 --window 1m --header x-api-key` | Per-API-key (or `--cookie session` per-session) limit |
|
|
181
|
+
| `securenow firewall hits --since 1h` | Who your blocks, rate limits and challenges actually stopped |
|
|
178
182
|
| `securenow challenge add <ip> --route /login` | Proof-of-work challenge instead of a hard block |
|
|
179
183
|
| `securenow revoke session <id>` / `revoke user <id>` | Kill stolen sessions (enforced via `securenow/sessions`) |
|
|
180
184
|
| `securenow trusted add <ip>` | Mark an IP as trusted |
|
|
181
185
|
|
|
186
|
+
### Rate limits, outside your code
|
|
187
|
+
|
|
188
|
+
Rate-limit rules live in SecureNow, not in your code: create them from the dashboard, the CLI or your agent (MCP), and every running instance picks them up in about 10 seconds, with no deploy. A rule matches a route (exact, prefix or regex), a method, optionally an IP/CIDR, an app and an environment, and counts per IP, globally, per request header value (`--header x-api-key`) or per cookie value (`--cookie session`). Header and cookie values are hashed; requests without them are counted per IP. Rules can stack (a burst limit and an hourly cap). Over the limit, the app answers `429` with `Retry-After`.
|
|
189
|
+
|
|
190
|
+
Counters live in each process by default. To share one counter across workers and servers, point the firewall at your Redis:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{ "config": { "firewall": { "rateLimitStore": "redis://your-redis:6379" } } }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The firewall never waits on Redis: it decides from what it already knows and reports each hit in the background (at most about one extra request per process can get through while a reply is in flight). If Redis is unreachable, counting falls back to in-process.
|
|
197
|
+
|
|
182
198
|
### Rules & detections
|
|
183
199
|
|
|
184
200
|
| Command | Description |
|
|
@@ -233,7 +249,7 @@ It syncs your app's blocklist plus SecureNow's known-bad IP feed, refreshed hour
|
|
|
233
249
|
|
|
234
250
|
SecureNow 8.11.2 redacts sign-in credentials named `code`, `otpCode`, `oneTimeCode`, `verificationCode`, and `passcode` by default, including case and separator variants. JSON, form, GraphQL, and multipart text capture use the same rule; URL query parameters and captured headers are also protected. Credential names match exactly after normalization, so `status_code` and `error_code` remain available for investigation. Redaction changes exported telemetry only.
|
|
235
251
|
- **Fails open.** If SecureNow is unreachable, your app serves traffic normally (`config.firewall.failMode: "open"` is the default).
|
|
236
|
-
- **Read/write split.** MCP read tools are read-only; write tools require explicit `confirm: true` plus a reason,
|
|
252
|
+
- **Read/write split.** MCP read tools are read-only; write tools require explicit `confirm: true` plus a reason, so your agent asks you before it blocks, rate-limits or revokes anything; every write is recorded and reversible.
|
|
237
253
|
|
|
238
254
|
More at [securenow.ai/security](https://securenow.ai/security).
|
|
239
255
|
|
package/SKILL-API.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow SDK — Agent Skill
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
Instrument any Node.js application with OpenTelemetry tracing, structured logging, request body capture, and a multi-layer IP firewall. Supports Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt 3, Vite (browser), and raw `http.createServer` — with zero code changes for most setups.
|
|
4
6
|
|
|
5
7
|
**CLI parity:** every capability exposed below (redaction, CIDR matching, log/span emission, firewall preload, config inspection) has an equivalent `securenow` CLI command. See [SKILL-CLI.md](./SKILL-CLI.md) for the terminal surface.
|
|
@@ -670,7 +672,7 @@ Make sure an API key is resolvable by running `npx securenow app connect` first.
|
|
|
670
672
|
### Instrument a Docker Container
|
|
671
673
|
|
|
672
674
|
```dockerfile
|
|
673
|
-
FROM node:
|
|
675
|
+
FROM node:24.22.1-slim
|
|
674
676
|
WORKDIR /app
|
|
675
677
|
COPY package*.json ./
|
|
676
678
|
RUN npm ci --omit=dev
|
package/SKILL-CLI.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow CLI — Agent Skill
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
Use the `securenow` CLI to perform security DevOps from the terminal: manage apps, investigate threats, control the firewall, analyze traces, handle false positives, and run instrumented Node.js processes. Every command supports `--json` for machine-readable output.
|
|
4
6
|
|
|
5
7
|
## Installation
|
package/app-config.js
CHANGED
|
@@ -75,6 +75,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
75
75
|
iptables: false,
|
|
76
76
|
cloud: null,
|
|
77
77
|
cloudDryRun: false,
|
|
78
|
+
rateLimitStore: null,
|
|
78
79
|
cloudflare: {
|
|
79
80
|
apiToken: null,
|
|
80
81
|
accountId: null,
|
|
@@ -126,6 +127,7 @@ const CONFIG_EXPLANATIONS = Object.freeze({
|
|
|
126
127
|
'config.firewall.iptables': 'Layer 3 OS firewall. Default false because it requires Linux root/CAP_NET_ADMIN.',
|
|
127
128
|
'config.firewall.cloud': 'Layer 4 WAF provider: cloudflare, aws, gcp, or null.',
|
|
128
129
|
'config.firewall.cloudDryRun': 'Log intended cloud WAF pushes without applying them.',
|
|
130
|
+
'config.firewall.rateLimitStore': 'Optional shared rate-limit counters: a redis:// or rediss:// URL. Unset = each process counts on its own. If the store is unreachable, counting falls back to in-process.',
|
|
129
131
|
'config.firewall.cloudflare': 'Cloudflare API token/account id for Layer 4 WAF. Secrets: do not commit.',
|
|
130
132
|
'config.firewall.aws': 'AWS WAF IP set configuration for Layer 4 WAF.',
|
|
131
133
|
'config.firewall.gcp': 'GCP Cloud Armor policy configuration for Layer 4 WAF.',
|
|
@@ -710,6 +712,8 @@ function resolveFirewallOptions() {
|
|
|
710
712
|
iptables: boolConfig('firewall.iptables', false),
|
|
711
713
|
cloud: configValue('firewall.cloud', null) || null,
|
|
712
714
|
cloudDryRun: boolConfig('firewall.cloudDryRun', false),
|
|
715
|
+
// Optional shared rate-limit counters: "redis://host:6379" (or rediss://). Unset = per process.
|
|
716
|
+
rateLimitStore: configValue('firewall.rateLimitStore', null) || null,
|
|
713
717
|
cloudflare: {
|
|
714
718
|
apiToken: resolveConfigPath('firewall.cloudflare.apiToken', [], null) || null,
|
|
715
719
|
accountId: resolveConfigPath('firewall.cloudflare.accountId', [], null) || null,
|
package/cli/firewall.js
CHANGED
|
@@ -216,4 +216,50 @@ async function appsList(args, flags) {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
// securenow firewall hits — who the firewall actually stopped (blocks, 429s, challenges),
|
|
220
|
+
// per rule and per IP, plus the latest hits. The quickest "is my rule working?" check.
|
|
221
|
+
async function hits(args, flags) {
|
|
222
|
+
requireAuth();
|
|
223
|
+
const s = ui.spinner('Loading firewall hits');
|
|
224
|
+
try {
|
|
225
|
+
const query = { since: flags.since || '24h' };
|
|
226
|
+
const appKey = await resolveAppKey(flags);
|
|
227
|
+
if (appKey) query.appKey = appKey;
|
|
228
|
+
if (flags.env || flags.environment) query.environment = flags.env || flags.environment;
|
|
229
|
+
if (flags.action) query.action = flags.action === 'rate-limited' ? 'rate_limited' : flags.action;
|
|
230
|
+
if (flags.source) query.source = flags.source === 'rate-limit' ? 'rate_limit' : flags.source;
|
|
231
|
+
if (flags.rule) query.ruleId = flags.rule;
|
|
232
|
+
if (flags.ip) query.ip = flags.ip;
|
|
233
|
+
if (flags.limit) query.limit = flags.limit;
|
|
234
|
+
const data = await api.get('/firewall/hits', { query });
|
|
235
|
+
s.stop('Firewall hits loaded');
|
|
236
|
+
if (flags.json) { ui.json(data); return; }
|
|
237
|
+
|
|
238
|
+
const label = { blocked: 'blocked', rate_limited: '429', captcha_challenged: 'challenged', captcha_passed: 'passed', captcha_failed: 'failed', allowed: 'allowed' };
|
|
239
|
+
const totals = Object.entries(data.totals || {}).map(([k, v]) => `${label[k] || k} ${v}`).join(' · ') || 'none';
|
|
240
|
+
console.log('');
|
|
241
|
+
console.log(` ${ui.c.bold('Hits')} ${ui.c.dim(`${new Date(data.from).toLocaleString()} → ${new Date(data.to).toLocaleString()}`)} ${totals}`);
|
|
242
|
+
console.log('');
|
|
243
|
+
if (!(data.byRule || []).length) {
|
|
244
|
+
console.log(` ${ui.c.dim('No blocks, rate limits or challenges in this window.')}`);
|
|
245
|
+
console.log('');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
console.log(` ${ui.c.bold('By rule')}`);
|
|
249
|
+
for (const r of data.byRule.slice(0, 15)) {
|
|
250
|
+
const what = r.rule || (r.ruleId ? `rule ${r.ruleId}` : r.matchedEntry ? `block ${r.matchedEntry}` : r.source);
|
|
251
|
+
console.log(` ${String(r.hits).padStart(6)} ${(label[r.action] || r.action).padEnd(10)} ${what} ${ui.c.dim(`${r.distinctIps} IP${r.distinctIps === 1 ? '' : 's'}${r.ruleId ? ` · ${r.ruleId}` : ''}`)}`);
|
|
252
|
+
}
|
|
253
|
+
console.log('');
|
|
254
|
+
console.log(` ${ui.c.bold('Top IPs')}`);
|
|
255
|
+
for (const r of (data.byIp || []).slice(0, 10)) {
|
|
256
|
+
console.log(` ${String(r.hits).padStart(6)} ${(label[r.action] || r.action).padEnd(10)} ${r.ip}`);
|
|
257
|
+
}
|
|
258
|
+
console.log('');
|
|
259
|
+
} catch (err) {
|
|
260
|
+
s.fail('Failed to load firewall hits');
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { status, testIp, enable, disable, appsList, hits };
|
package/cli/monitor.js
CHANGED
|
@@ -269,7 +269,7 @@ async function logsList(args, flags) {
|
|
|
269
269
|
|
|
270
270
|
console.log('');
|
|
271
271
|
for (const log of logs) {
|
|
272
|
-
const level = (log.severityText || log.level || 'INFO').toUpperCase();
|
|
272
|
+
const level = (log.severity_text || log.severityText || log.level || 'INFO').toUpperCase();
|
|
273
273
|
const levelColor = {
|
|
274
274
|
ERROR: ui.c.red, FATAL: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
|
|
275
275
|
INFO: ui.c.cyan, DEBUG: ui.c.dim, TRACE: ui.c.dim,
|
|
@@ -277,13 +277,14 @@ async function logsList(args, flags) {
|
|
|
277
277
|
|
|
278
278
|
const parsedTime = ui.parseTimestamp(log.timestamp);
|
|
279
279
|
const time = ui.c.dim(parsedTime ? parsedTime.toLocaleTimeString() : '');
|
|
280
|
-
const body = log.body || log.message ||
|
|
280
|
+
const body = log.body || log.message || '';
|
|
281
281
|
|
|
282
282
|
const env = log.environment ? ` ${ui.c.dim(`[${log.environment}]`)}` : '';
|
|
283
283
|
console.log(` ${time} ${levelColor(level.padEnd(7))}${env} ${body}`);
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
const traceId = log.trace_id || log.traceId;
|
|
286
|
+
if (traceId && flags.verbose) {
|
|
287
|
+
console.log(` ${ui.c.dim(` trace=${traceId} span=${log.span_id || log.spanId || ''}`)}`);
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
console.log('');
|
|
@@ -315,7 +316,7 @@ async function logsTrace(args, flags) {
|
|
|
315
316
|
|
|
316
317
|
console.log('');
|
|
317
318
|
for (const log of logs) {
|
|
318
|
-
const level = (log.severityText || log.level || 'INFO').toUpperCase();
|
|
319
|
+
const level = (log.severity_text || log.severityText || log.level || 'INFO').toUpperCase();
|
|
319
320
|
const levelColor = {
|
|
320
321
|
ERROR: ui.c.red, FATAL: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
|
|
321
322
|
INFO: ui.c.cyan, DEBUG: ui.c.dim, TRACE: ui.c.dim,
|
package/cli/rateLimits.js
CHANGED
|
@@ -197,7 +197,7 @@ async function show(args, flags) {
|
|
|
197
197
|
['Status', r.status || '-'],
|
|
198
198
|
['Target', describeTarget(r)],
|
|
199
199
|
['Limit', `${r.limit}/${r.windowSeconds}s`],
|
|
200
|
-
['Key by', r.keyBy || 'ip'],
|
|
200
|
+
['Key by', r.keyName ? `${r.keyBy} ${r.keyName}` : (r.keyBy || 'ip')],
|
|
201
201
|
['App', r.applicationKey || 'all apps'],
|
|
202
202
|
['Environment', r.environment || 'all envs'],
|
|
203
203
|
['Reason', r.reason || '-'],
|
|
@@ -232,7 +232,8 @@ async function add(args, flags) {
|
|
|
232
232
|
pathPattern,
|
|
233
233
|
pathMatchMode: flags.mode || flags['path-mode'] || (pathPattern ? 'prefix' : 'prefix'),
|
|
234
234
|
method: flags.method || 'ALL',
|
|
235
|
-
keyBy: flags['key-by'] || flags.keyBy || 'ip',
|
|
235
|
+
keyBy: flags['key-by'] || flags.keyBy || (flags.header ? 'header' : flags.cookie ? 'cookie' : 'ip'),
|
|
236
|
+
keyName: flags['key-name'] || flags.keyName || flags.header || flags.cookie || '',
|
|
236
237
|
limit: Number(flags.limit),
|
|
237
238
|
windowSeconds: parseWindow(flags.window || flags['window-seconds']),
|
|
238
239
|
reason: flags.reason || '',
|
package/cli.js
CHANGED
|
@@ -387,6 +387,7 @@ const COMMANDS = {
|
|
|
387
387
|
enable: { desc: 'Turn the firewall ON for an app environment', usage: 'securenow firewall enable [--app <key>] [--env production]', flags: { app: 'App key (defaults to logged-in app)', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').enable(a, f) },
|
|
388
388
|
disable: { desc: 'Turn the firewall OFF for an app environment', usage: 'securenow firewall disable [--app <key>] [--env local]', flags: { app: 'App key (defaults to logged-in app)', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').disable(a, f) },
|
|
389
389
|
'test-ip': { desc: 'Check if an IP would be blocked', usage: 'securenow firewall test-ip <ip> [--path /admin/users] [--method GET] [--env production]', flags: { env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').testIp(a, f) },
|
|
390
|
+
hits: { desc: 'Who the firewall stopped: blocks, 429s and challenges per rule and per IP', usage: 'securenow firewall hits [--since 24h] [--action blocked|rate_limited|captcha_challenged] [--rule <id>] [--ip <ip>] [--env production] [--json]', flags: { since: 'Look-back window, e.g. 30m, 6h, 7d (default 24h)', action: 'blocked | rate_limited | captcha_challenged', source: 'blocklist | allowlist | rate_limit | challenge', rule: 'Rule id', ip: 'Client IP', env: 'Environment', limit: 'Recent hits to fetch' }, run: (a, f) => require('./cli/firewall').hits(a, f) },
|
|
390
391
|
},
|
|
391
392
|
defaultSub: 'status',
|
|
392
393
|
},
|
|
@@ -410,7 +411,7 @@ const COMMANDS = {
|
|
|
410
411
|
},
|
|
411
412
|
sub: {
|
|
412
413
|
list: { desc: 'List rate-limit remediation rules', run: (a, f) => require('./cli/rateLimits').list(a, f) },
|
|
413
|
-
add: { desc: 'Create a rate-limit rule', usage: 'securenow ratelimit add [ip] --route /api/login --limit 5 --window 1m --duration 24h', run: (a, f) => require('./cli/rateLimits').add(a, f) },
|
|
414
|
+
add: { desc: 'Create a rate-limit rule', usage: 'securenow ratelimit add [ip] --route /api/login --limit 5 --window 1m --duration 24h [--key-by ip|global|header|cookie] [--key-name x-api-key | --header x-api-key | --cookie session]', run: (a, f) => require('./cli/rateLimits').add(a, f) },
|
|
414
415
|
parse: { desc: 'Parse natural language into a rate-limit draft', usage: 'securenow ratelimit parse "rate limit /api/login to 2 attempts per minute"', run: (a, f) => require('./cli/rateLimits').parseText(a, f) },
|
|
415
416
|
'from-text': { desc: 'Create a rate-limit rule from natural language', usage: 'securenow ratelimit from-text "rate limit /api/login to 2 attempts per minute" --yes', run: (a, f) => require('./cli/rateLimits').fromText(a, f) },
|
|
416
417
|
show: { desc: 'Show one rate-limit rule', usage: 'securenow ratelimit show <id>', run: (a, f) => require('./cli/rateLimits').show(a, f) },
|
package/crash-capture.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Crash capture: an uncaught exception or an unhandled promise rejection is
|
|
4
|
+
// recorded as a FATAL log record (with exception.type/message/stacktrace), the
|
|
5
|
+
// log pipeline is flushed, and the process then exits the way Node would have
|
|
6
|
+
// (stack on stderr, exit code 1). When the app registered its own handler, the
|
|
7
|
+
// error is recorded as ERROR and the app's handler decides what happens next.
|
|
8
|
+
// Transient OTLP exporter errors are still swallowed so telemetry outages never
|
|
9
|
+
// crash the host app.
|
|
10
|
+
|
|
11
|
+
const util = require('util');
|
|
12
|
+
|
|
13
|
+
const FLUSH_TIMEOUT_MS = 2000;
|
|
14
|
+
|
|
15
|
+
function unhandledRejectionMode() {
|
|
16
|
+
const args = [...process.execArgv, ...String(process.env.NODE_OPTIONS || '').split(/\s+/)];
|
|
17
|
+
for (const a of args) {
|
|
18
|
+
const m = /^--unhandled-rejections=(\S+)$/.exec(a);
|
|
19
|
+
if (m) return m[1];
|
|
20
|
+
}
|
|
21
|
+
return 'throw';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function describeError(err) {
|
|
25
|
+
if (err instanceof Error) {
|
|
26
|
+
const type = err.name || (err.constructor && err.constructor.name) || 'Error';
|
|
27
|
+
const message = err.message || '';
|
|
28
|
+
return { type, message, stack: err.stack || `${type}: ${message}` };
|
|
29
|
+
}
|
|
30
|
+
const text = util.inspect(err, { depth: 3, breakLength: Infinity });
|
|
31
|
+
const type = err && typeof err === 'object' ? (err.constructor && err.constructor.name) || 'Object' : typeof err;
|
|
32
|
+
return { type, message: text, stack: text };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function installCrashCapture({ getLogger, flush, isSdkError, onSdkError, write } = {}) {
|
|
36
|
+
const writeErr = write || ((s) => { try { process.stderr.write(s); } catch (_) {} });
|
|
37
|
+
let crashing = false;
|
|
38
|
+
|
|
39
|
+
function record(err, origin, fatal) {
|
|
40
|
+
let logger = null;
|
|
41
|
+
try { logger = getLogger ? getLogger() : null; } catch (_) {}
|
|
42
|
+
if (!logger) return;
|
|
43
|
+
const d = describeError(err);
|
|
44
|
+
const what = origin === 'unhandledRejection' ? 'Unhandled promise rejection' : 'Uncaught exception';
|
|
45
|
+
try {
|
|
46
|
+
logger.emit({
|
|
47
|
+
severityNumber: fatal ? 21 : 17,
|
|
48
|
+
severityText: fatal ? 'FATAL' : 'ERROR',
|
|
49
|
+
body: `${what}${fatal ? ', process crashed' : ' (handled by the app)'}: ${d.stack}`,
|
|
50
|
+
attributes: {
|
|
51
|
+
'log.source': 'crash',
|
|
52
|
+
'exception.type': d.type,
|
|
53
|
+
'exception.message': d.message.slice(0, 4000),
|
|
54
|
+
'exception.stacktrace': d.stack.slice(0, 16000),
|
|
55
|
+
'exception.origin': origin || 'uncaughtException',
|
|
56
|
+
'exception.escaped': Boolean(fatal),
|
|
57
|
+
'process.pid': process.pid,
|
|
58
|
+
'process.uptime_s': Math.round(process.uptime()),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function flushQuietly() {
|
|
65
|
+
try {
|
|
66
|
+
return Promise.resolve(flush ? flush() : undefined).catch(() => {});
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return Promise.resolve();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Print like Node's default handler, give the exporter a bounded window to
|
|
73
|
+
// ship the crash record, then exit with code 1.
|
|
74
|
+
function crash(err, origin) {
|
|
75
|
+
if (crashing) process.exit(1);
|
|
76
|
+
crashing = true;
|
|
77
|
+
const d = describeError(err);
|
|
78
|
+
if (origin === 'unhandledRejection' && !(err instanceof Error)) {
|
|
79
|
+
writeErr(`[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "${d.message}".]\n`);
|
|
80
|
+
} else {
|
|
81
|
+
writeErr(`${d.stack}\n`);
|
|
82
|
+
}
|
|
83
|
+
writeErr(`\nNode.js ${process.version}\n`);
|
|
84
|
+
const hardStop = setTimeout(() => process.exit(1), FLUSH_TIMEOUT_MS + 250);
|
|
85
|
+
Promise.race([flushQuietly(), new Promise((r) => setTimeout(r, FLUSH_TIMEOUT_MS))])
|
|
86
|
+
.then(() => { clearTimeout(hardStop); process.exit(1); });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.on('uncaughtException', function securenowUncaughtException(err, origin) {
|
|
90
|
+
if (isSdkError && isSdkError(err)) {
|
|
91
|
+
if (onSdkError) onSdkError('error', err, origin);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const appHandles = process.listenerCount('uncaughtException') > 1;
|
|
95
|
+
record(err, origin, !appHandles);
|
|
96
|
+
if (appHandles) {
|
|
97
|
+
flushQuietly();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
crash(err, origin);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
process.on('unhandledRejection', function securenowUnhandledRejection(reason) {
|
|
104
|
+
if (isSdkError && isSdkError(reason)) {
|
|
105
|
+
if (onSdkError) onSdkError('rejection', reason, 'unhandledRejection');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const appHandles = process.listenerCount('unhandledRejection') > 1;
|
|
109
|
+
const mode = unhandledRejectionMode();
|
|
110
|
+
if (appHandles || mode === 'warn' || mode === 'none' || mode === 'warn-with-error-code') {
|
|
111
|
+
record(reason, 'unhandledRejection', false);
|
|
112
|
+
if (!appHandles && mode === 'warn-with-error-code') {
|
|
113
|
+
writeErr(`(node:${process.pid}) UnhandledPromiseRejectionWarning: ${describeError(reason).stack}\n`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
flushQuietly();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Default (--unhandled-rejections=throw): an unhandled rejection crashes.
|
|
120
|
+
record(reason, 'unhandledRejection', true);
|
|
121
|
+
crash(reason, 'unhandledRejection');
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { installCrashCapture, describeError, unhandledRejectionMode };
|
package/firewall.js
CHANGED
|
@@ -44,6 +44,10 @@ let _lastAllowlistSyncEtag = null;
|
|
|
44
44
|
let _rateLimitRules = [];
|
|
45
45
|
let _lastRateLimitVersion = null;
|
|
46
46
|
let _rateLimitBuckets = new Map();
|
|
47
|
+
// Optional shared counters (config.firewall.rateLimitStore = "redis://…"). When it is ready,
|
|
48
|
+
// rate limits count across every worker and server; otherwise each process counts on its own.
|
|
49
|
+
let _rateLimitStore = null;
|
|
50
|
+
let _sharedBuckets = new Map();
|
|
47
51
|
|
|
48
52
|
// CAPTCHA / proof-of-work challenge state. _challengeConfig.secret signs the
|
|
49
53
|
// challenge tokens + clearance cookies (delivered per-app over sync). When a
|
|
@@ -914,10 +918,43 @@ function ruleIpMatches(rule, ip) {
|
|
|
914
918
|
// Matches the raw path and its canonical aliases (case, trailing/duplicate slashes, percent-encoding).
|
|
915
919
|
const { rulePathMatches } = require('./firewall-paths');
|
|
916
920
|
|
|
917
|
-
function
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
+
function readCookie(req, name) {
|
|
922
|
+
const header = req && req.headers && req.headers.cookie;
|
|
923
|
+
if (!header || !name) return '';
|
|
924
|
+
for (const part of String(header).split(';')) {
|
|
925
|
+
const idx = part.indexOf('=');
|
|
926
|
+
if (idx === -1) continue;
|
|
927
|
+
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
|
|
928
|
+
}
|
|
929
|
+
return '';
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function hashSubject(value) {
|
|
933
|
+
return require('crypto').createHash('sha256').update(String(value)).digest('hex').slice(0, 24);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// What one bucket counts. keyBy "header"/"cookie" counts per value of that header (an API key,
|
|
937
|
+
// a user or tenant id) or cookie (a session), hashed so no raw credential is ever kept or sent
|
|
938
|
+
// to a shared store. A request without the header/cookie is counted per IP instead.
|
|
939
|
+
function rateLimitSubject(rule, ip, req) {
|
|
940
|
+
if (rule.keyBy === 'global') return 'global';
|
|
941
|
+
if (rule.keyBy === 'header' || rule.keyBy === 'cookie') {
|
|
942
|
+
const name = String(rule.keyName || '').trim();
|
|
943
|
+
const raw = rule.keyBy === 'header'
|
|
944
|
+
? (req && req.headers ? req.headers[name.toLowerCase()] : '')
|
|
945
|
+
: readCookie(req, name);
|
|
946
|
+
const value = Array.isArray(raw) ? raw.join(',') : String(raw || '').trim();
|
|
947
|
+
if (value) return `${rule.keyBy}:${hashSubject(value)}`;
|
|
948
|
+
}
|
|
949
|
+
return ip;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function rateLimitRuleId(rule) {
|
|
953
|
+
return rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function rateLimitKey(rule, ip, req) {
|
|
957
|
+
return `${rateLimitRuleId(rule)}|${rateLimitSubject(rule, ip, req)}`;
|
|
921
958
|
}
|
|
922
959
|
|
|
923
960
|
function checkBlocklistRules(req, ip) {
|
|
@@ -958,7 +995,39 @@ function checkRateLimitRules(req, ip) {
|
|
|
958
995
|
const limit = Math.max(1, Number(rule.limit || 1) || 1);
|
|
959
996
|
const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
|
|
960
997
|
const windowMs = windowSeconds * 1000;
|
|
961
|
-
const key = rateLimitKey(rule, ip);
|
|
998
|
+
const key = rateLimitKey(rule, ip, req);
|
|
999
|
+
|
|
1000
|
+
if (_rateLimitStore && _rateLimitStore.ready) {
|
|
1001
|
+
// Shared counters: windows aligned to the clock so every process agrees on the bucket.
|
|
1002
|
+
// Decide from what is already known (this process's count, or the latest shared total
|
|
1003
|
+
// plus requests still in flight) and report the hit to the store in the background.
|
|
1004
|
+
const windowIndex = Math.floor(now / windowMs);
|
|
1005
|
+
let shared = _sharedBuckets.get(key);
|
|
1006
|
+
if (!shared || shared.windowIndex !== windowIndex) {
|
|
1007
|
+
shared = { windowIndex, local: 0, shared: 0, inflight: 0 };
|
|
1008
|
+
_sharedBuckets.set(key, shared);
|
|
1009
|
+
}
|
|
1010
|
+
if (Math.max(shared.local, shared.shared + shared.inflight) >= limit) {
|
|
1011
|
+
return {
|
|
1012
|
+
rule,
|
|
1013
|
+
ruleId: rule.id || rule._id || '',
|
|
1014
|
+
limit,
|
|
1015
|
+
windowSeconds,
|
|
1016
|
+
retryAfter: Math.max(1, Math.ceil(((windowIndex + 1) * windowMs - now) / 1000)),
|
|
1017
|
+
path,
|
|
1018
|
+
shared: true,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
shared.local++;
|
|
1022
|
+
shared.inflight++;
|
|
1023
|
+
const entry = shared;
|
|
1024
|
+
_rateLimitStore.incr(`securenow:rl:${key}:${windowIndex}`, windowMs + 1000)
|
|
1025
|
+
.then((total) => { if (Number.isFinite(total)) entry.shared = Math.max(entry.shared, total); })
|
|
1026
|
+
.catch(() => {})
|
|
1027
|
+
.finally(() => { entry.inflight = Math.max(0, entry.inflight - 1); });
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
962
1031
|
let bucket = _rateLimitBuckets.get(key);
|
|
963
1032
|
if (!bucket || now - bucket.windowStart >= windowMs) {
|
|
964
1033
|
bucket = { windowStart: now, count: 0 };
|
|
@@ -983,11 +1052,21 @@ function checkRateLimitRules(req, ip) {
|
|
|
983
1052
|
}
|
|
984
1053
|
|
|
985
1054
|
function pruneRateLimitBuckets(rules) {
|
|
986
|
-
const ids = new Set((rules || []).map((rule) => String(rule
|
|
987
|
-
for (const
|
|
1055
|
+
const ids = new Set((rules || []).map((rule) => String(rateLimitRuleId(rule))));
|
|
1056
|
+
for (const buckets of [_rateLimitBuckets, _sharedBuckets]) {
|
|
1057
|
+
for (const key of buckets.keys()) {
|
|
1058
|
+
const idx = key.indexOf('|');
|
|
1059
|
+
const ruleKey = idx === -1 ? key : key.slice(0, idx);
|
|
1060
|
+
if (!ids.has(ruleKey)) buckets.delete(key);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// Drop shared entries from past windows so the map cannot grow without bound.
|
|
1064
|
+
const now = Date.now();
|
|
1065
|
+
const windowMsById = new Map((rules || []).map((r) => [String(rateLimitRuleId(r)), Math.max(1, Number(r.windowSeconds) || 60) * 1000]));
|
|
1066
|
+
for (const [key, entry] of _sharedBuckets) {
|
|
988
1067
|
const idx = key.indexOf('|');
|
|
989
|
-
const
|
|
990
|
-
if (
|
|
1068
|
+
const windowMs = windowMsById.get(idx === -1 ? key : key.slice(0, idx)) || 60000;
|
|
1069
|
+
if (entry.windowIndex < Math.floor(now / windowMs)) _sharedBuckets.delete(key);
|
|
991
1070
|
}
|
|
992
1071
|
}
|
|
993
1072
|
|
|
@@ -1100,6 +1179,7 @@ function sendChallengeResponse(req, res, ip, decision) {
|
|
|
1100
1179
|
source: 'challenge',
|
|
1101
1180
|
statusCode: 403,
|
|
1102
1181
|
ip,
|
|
1182
|
+
ruleId: String(decision.ruleId || ''),
|
|
1103
1183
|
matchedEntry: rid,
|
|
1104
1184
|
method: req.method || '',
|
|
1105
1185
|
path: decision.path || req.url || '',
|
|
@@ -1249,6 +1329,7 @@ function firewallRequestHandler(req, res) {
|
|
|
1249
1329
|
reportFirewallEvent({
|
|
1250
1330
|
source: 'blocklist',
|
|
1251
1331
|
ip,
|
|
1332
|
+
ruleId: String(blockRuleDecision.ruleId || ''),
|
|
1252
1333
|
matchedEntry: blockRuleDecision.matchedEntry || ip,
|
|
1253
1334
|
method: req.method || '',
|
|
1254
1335
|
path: blockRuleDecision.path || req.url || '',
|
|
@@ -1282,6 +1363,8 @@ function firewallRequestHandler(req, res) {
|
|
|
1282
1363
|
source: 'rate_limit',
|
|
1283
1364
|
statusCode: 429,
|
|
1284
1365
|
ip,
|
|
1366
|
+
ruleId: String(rateLimitDecision.ruleId || ''),
|
|
1367
|
+
keyBy: (rateLimitDecision.rule && rateLimitDecision.rule.keyBy) || 'ip',
|
|
1285
1368
|
matchedEntry: rateLimitDecision.ruleId || '',
|
|
1286
1369
|
method: req.method || '',
|
|
1287
1370
|
path: rateLimitDecision.path || req.url || '',
|
|
@@ -1387,10 +1470,27 @@ function init(options) {
|
|
|
1387
1470
|
if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set config.firewall.cloud=cloudflare|aws|gcp)');
|
|
1388
1471
|
}
|
|
1389
1472
|
|
|
1473
|
+
const storeSpec = _options.rateLimitStore !== undefined
|
|
1474
|
+
? _options.rateLimitStore
|
|
1475
|
+
: require('./app-config').resolveFirewallOptions().rateLimitStore;
|
|
1476
|
+
if (storeSpec) {
|
|
1477
|
+
try {
|
|
1478
|
+
_rateLimitStore = require('./rate-limit-store').createRateLimitStore(storeSpec, {
|
|
1479
|
+
log: (...args) => { if (_options.log) fwLog(...args); },
|
|
1480
|
+
});
|
|
1481
|
+
if (_options.log && _rateLimitStore) fwLog('[securenow] Firewall: shared rate-limit counters → %s', _rateLimitStore.describe());
|
|
1482
|
+
} catch (e) {
|
|
1483
|
+
_rateLimitStore = null;
|
|
1484
|
+
if (_options.log) fwWarn('[securenow] Firewall: rate-limit store disabled (%s); counting in-process', e.message);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1390
1488
|
startSyncLoop();
|
|
1391
1489
|
}
|
|
1392
1490
|
|
|
1393
1491
|
function shutdown() {
|
|
1492
|
+
if (_rateLimitStore) { try { _rateLimitStore.close(); } catch (_) {} _rateLimitStore = null; }
|
|
1493
|
+
_sharedBuckets = new Map();
|
|
1394
1494
|
if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
|
|
1395
1495
|
if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
|
|
1396
1496
|
if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
|
|
@@ -1459,4 +1559,15 @@ function isRemoteEnabled() { return _remoteEnabled !== false; }
|
|
|
1459
1559
|
|
|
1460
1560
|
function getChallengeRules() { return _challengeRules; }
|
|
1461
1561
|
|
|
1462
|
-
|
|
1562
|
+
// Test hooks (not part of the public API): drive rate-limit decisions without a server or sync.
|
|
1563
|
+
const _test = {
|
|
1564
|
+
setRateLimitRules(rules) { _rateLimitRules = Array.isArray(rules) ? rules : []; _rateLimitBuckets = new Map(); _sharedBuckets = new Map(); },
|
|
1565
|
+
setRateLimitStore(store) { _rateLimitStore = store || null; _sharedBuckets = new Map(); },
|
|
1566
|
+
checkRateLimitRules,
|
|
1567
|
+
rateLimitSubject,
|
|
1568
|
+
rateLimitKey,
|
|
1569
|
+
pruneRateLimitBuckets,
|
|
1570
|
+
sharedBucketCount: () => _sharedBuckets.size,
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled, _test };
|