securenow 7.7.9 → 7.7.11

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
@@ -157,7 +157,8 @@ npx securenow login --global
157
157
  # Check who you're logged in as (shows auth source)
158
158
  npx securenow whoami
159
159
 
160
- # Already have a firewall key? Store it without re-running login:
160
+ # Need or already have a firewall key? Create or store it without re-running login:
161
+ npx securenow api-key create --name "CLI firewall"
161
162
  npx securenow api-key set snk_live_abc123... # --global for ~/.securenow/
162
163
  npx securenow api-key show # masked key + source
163
164
  npx securenow api-key clear # remove just the key
@@ -186,7 +187,7 @@ codex mcp add securenow -- npx securenow mcp
186
187
  npx -p securenow securenow-mcp
187
188
  ```
188
189
 
189
- The MCP surface exposes tools for applications, traces, logs, firewall, IP intelligence, forensics, notifications, blocklist, allowlist, trusted IPs, and docs-backed prompts/resources. Write actions require `confirm:true` and a reason. Use `securenow_blocklist_unblock` to stop firewall enforcement while keeping the block report/history; `securenow_blocklist_remove` is a compatibility alias.
190
+ The MCP surface exposes tools for applications, traces, logs, firewall, IP intelligence, forensics, notifications, blocklist, allowlist, trusted IPs, rate-limit remediation, and docs-backed prompts/resources. Write actions require `confirm:true` and a reason. Use `securenow_blocklist_unblock` to stop firewall enforcement while keeping the block report/history; `securenow_blocklist_remove` is a compatibility alias.
190
191
 
191
192
  For hosted clients, SecureNow can expose the same surface at `https://api.securenow.ai/mcp`. The hosted endpoint uses the same API authentication and scope checks as the rest of SecureNow.
192
193
 
@@ -256,6 +257,8 @@ npx securenow alerts rules
256
257
  npx securenow alerts rules show <rule-id>
257
258
  npx securenow alerts rules update <rule-id> --applications-all
258
259
  npx securenow alerts rules update <rule-id> --apps key1,key2
260
+ npx securenow alerts rules dry-run-query <rule-id> --sql @candidate.sql --app <key> --wait
261
+ npx securenow alerts rules tune-query <rule-id> --sql @candidate.sql --reason "Preserve exploit detector, remove noisy broad match" --apply-globally --yes
259
262
  npx securenow alerts channels
260
263
  npx securenow alerts history --limit 20
261
264
  ```
@@ -489,7 +492,8 @@ npx securenow logs --json --level error | jq '.logs'
489
492
  | | `apps info <id>` | Application details |
490
493
  | | `apps delete <id>` | Delete application |
491
494
  | | `apps default <key>` | Set default app |
492
- | **API Key** | `api-key set <snk_live_...> [--global]` | Save firewall key to `.securenow/credentials.json` |
495
+ | **API Key** | `api-key create [--name "CLI firewall"] [--global]` | Mint and save a firewall key with your logged-in session |
496
+ | | `api-key set <snk_live_...> [--global]` | Save firewall key to `.securenow/credentials.json` |
493
497
  | | `api-key show` | Show masked key + source |
494
498
  | | `api-key clear [--global]` | Remove stored key (leaves session/app) |
495
499
  | **Observe** | `traces` | List traces |
@@ -525,6 +529,9 @@ npx securenow logs --json --level error | jq '.logs'
525
529
  | | `blocklist unblock <id>` | Unblock IP and retain report/history |
526
530
  | | `blocklist remove <id>` | Compatibility alias for unblock |
527
531
  | | `blocklist stats` | Block stats |
532
+ | | `ratelimit parse "<request>"` | Fill a rate-limit draft from natural language |
533
+ | | `ratelimit from-text "<request>" --yes` | Create a soft rate-limit rule from natural language |
534
+ | | `ratelimit test <ip> --path /api/login` | Check whether a request would match rate-limit rules |
528
535
  | | `allowlist` | Allowed IPs (restrict-mode) |
529
536
  | | `allowlist add <ip>` | Allow IP (`--label`, `--reason`) |
530
537
  | | `allowlist remove <id>` | Remove from allowlist |
@@ -1287,6 +1294,7 @@ SecureNow provides multiple entry points depending on your needs:
1287
1294
  | `securenow/nuxt` | `modules: ['securenow/nuxt']` | Yes | Yes | Nuxt 3 module |
1288
1295
  | `securenow/nextjs-webpack-config` | `withSecureNow(config)` | - | - | Next.js config wrapper |
1289
1296
  | `securenow/firewall` | `require('securenow/firewall').init({...})` | No | Yes | Programmatic firewall API |
1297
+ | `securenow/rate-limits` | `require('securenow/rate-limits').parseRateLimitText(...)` | No | Yes | Rate-limit remediation API helper |
1290
1298
  | `securenow/tracing` | `require('securenow/tracing')` | Yes | No | Programmatic tracing API |
1291
1299
 
1292
1300
  ---
package/README.md CHANGED
@@ -175,6 +175,7 @@ npx securenow login --global # save to ~/.securenow/ instead
175
175
  npx securenow login --token <TOKEN> # headless (CI)
176
176
  npx securenow init --env local # scaffold framework files + local env scope
177
177
  npx securenow credentials runtime --env production # write tokenless production credentials file
178
+ npx securenow api-key create --name "CLI firewall" # mint + store firewall key
178
179
  npx securenow api-key set snk_live_... # store firewall key in .securenow/credentials.json
179
180
 
180
181
  # Apps
@@ -214,7 +215,7 @@ codex mcp add securenow -- npx securenow mcp
214
215
  npx -p securenow securenow-mcp
215
216
  ```
216
217
 
217
- The MCP server reuses the same project-local `.securenow/credentials.json` as the CLI and SDK. It exposes tools for apps, traces, logs, firewall, IP intelligence, forensics, blocklist/allowlist/trusted IPs, plus resources for the bundled SecureNow docs and setup prompts.
218
+ The MCP server reuses the same project-local `.securenow/credentials.json` as the CLI and SDK. It exposes tools for apps, traces, logs, firewall, IP intelligence, forensics, blocklist/allowlist/trusted IPs, rate-limit remediation, plus resources for the bundled SecureNow docs and setup prompts.
218
219
 
219
220
  ---
220
221
 
@@ -301,6 +302,7 @@ After install, the `securenow` CLI is available via `npx securenow` or globally
301
302
  | `securenow logout` | Clear project-local credentials |
302
303
  | `securenow logout --global` | Clear ~/.securenow/ instead |
303
304
  | `securenow whoami` | Show current session (email, app, expiry) |
305
+ | `securenow api-key create [--name "CLI firewall"]` | Mint and store a firewall key using your session token |
304
306
  | `securenow api-key set <snk_live_...>` | Store firewall key in `.securenow/credentials.json` (`--global` for `~/.securenow/`) |
305
307
  | `securenow api-key show` | Print masked key + source file |
306
308
  | `securenow api-key clear` | Remove stored key (`--global` for `~/.securenow/`) |
@@ -334,6 +336,8 @@ After install, the `securenow` CLI is available via `npx securenow` or globally
334
336
  | `securenow notifications` | List notifications |
335
337
  | `securenow notifications unread` | Unread count |
336
338
  | `securenow alerts rules` | List alert rules |
339
+ | `securenow alerts rules dry-run-query <id> --sql @candidate.sql --wait` | Dry-run candidate alert SQL without saving |
340
+ | `securenow alerts rules tune-query <id> --sql @candidate.sql --apply-globally --yes` | Admin: update a shared system rule query mapping |
337
341
  | `securenow alerts history` | Alert history |
338
342
 
339
343
  ### Investigate
@@ -359,6 +363,8 @@ After install, the `securenow` CLI is available via `npx securenow` or globally
359
363
  | `securenow blocklist` | List blocked IPs |
360
364
  | `securenow blocklist add <ip> [--reason ...]` | Block an IP |
361
365
  | `securenow blocklist unblock <id> [--reason ...]` | Stop enforcement and keep block history |
366
+ | `securenow ratelimit parse "<request>"` | Fill a rate-limit rule draft from natural language |
367
+ | `securenow ratelimit from-text "<request>" --yes` | Create a soft rate-limit rule from natural language |
362
368
  | `securenow allowlist add <ip>` | Allow an IP (restrict-mode) |
363
369
  | `securenow trusted add <ip>` | Mark an IP as trusted |
364
370
 
package/SKILL-API.md CHANGED
@@ -4,7 +4,9 @@ Instrument any Node.js application with OpenTelemetry tracing, structured loggin
4
4
 
5
5
  **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.
6
6
 
7
- **MCP parity (v7.5+):** `npx securenow mcp` starts a local stdio MCP server for Codex, Claude, and other MCP clients. It reuses the same `.securenow/credentials.json` file as the CLI/SDK and exposes SecureNow tools, bundled docs resources, and setup prompts to agents.
7
+ **MCP parity (v7.5+):** `npx securenow mcp` starts a local stdio MCP server for Codex, Claude, and other MCP clients. It reuses the same `.securenow/credentials.json` file as the CLI/SDK and exposes SecureNow tools, bundled docs resources, and setup prompts to agents. Alert-rule operators can inspect notifications, read exact `metadata.matchedSubdetectors`, dry-run candidate SQL with `securenow_alert_rule_candidate_test`, and apply global system-rule query fixes with `securenow_alert_rule_query_update` when evidence is clear.
8
+
9
+ **Noisy alert-rule reviews:** prefer fixing a generic system-rule detector over creating customer-specific false positives. Dry-run candidate SQL first, preserve tenant scoping with `__USER_APP_KEYS__`, keep exploit-specific indicators, then save the shared query mapping only with an audit reason and explicit confirmation.
8
10
 
9
11
  ## Installation
10
12
 
@@ -97,9 +99,10 @@ operator needs to ensure those defaults immediately.
97
99
  | `securenow/nextjs-middleware` | Edge middleware body capture; exports `middleware()`, `redactSensitiveData()`, `DEFAULT_SENSITIVE_FIELDS` | CJS |
98
100
  | `securenow/nextjs-wrapper` | Route handler wrappers; exports `withSecureNow()`, `withSecureNowAsync()`, `captureRequestBody()`, `redactSensitiveData()` | CJS |
99
101
  | `securenow/nextjs-auto-capture` | Auto-patch Next request for body capture; exports `patchNextRequest()`, `safeBodyCapture()`, `redactSensitiveData()`, `isBodyCaptureEnabled()` | CJS |
100
- | `securenow/nuxt` | Nuxt 3 module (add to `modules` array) | ESM |
101
- | `securenow/firewall` | Standalone firewall; exports `init()`, `shutdown()`, `getStats()`, `getMatcher()`, `getAllowlistMatcher()` | CJS |
102
- | `securenow/firewall-only` | Preload: dotenv + firewall only, no tracing | Preload (`-r`) |
102
+ | `securenow/nuxt` | Nuxt 3 module (add to `modules` array) | ESM |
103
+ | `securenow/firewall` | Standalone firewall; exports `init()`, `shutdown()`, `getStats()`, `getMatcher()`, `getAllowlistMatcher()` | CJS |
104
+ | `securenow/rate-limits` | Rate-limit remediation API helper; exports `parseRateLimitText()`, `createRateLimitFromText()`, `createRateLimit()`, `listRateLimits()` | CJS |
105
+ | `securenow/firewall-only` | Preload: dotenv + firewall only, no tracing | Preload (`-r`) |
103
106
  | `securenow/cidr` | CIDR utilities; exports `createMatcher()`, `ipToInt()`, `parseCidr()`, `matchesCidr()` | CJS |
104
107
  | `securenow/resolve-ip` | IP resolution; exports `resolveClientIp()`, `resolveSocketIp()`, `isFromTrustedProxy()` | CJS |
105
108
  | `securenow/console-instrumentation` | Console→OTLP bridge; exports `originalConsole`, `restoreConsole()` | CJS |
package/SKILL-CLI.md CHANGED
@@ -24,7 +24,7 @@ codex mcp add securenow -- npx securenow mcp
24
24
  npx -p securenow securenow-mcp
25
25
  ```
26
26
 
27
- The MCP server reuses `.securenow/credentials.json` and exposes apps, traces, logs, firewall, IP intelligence, forensics, notifications, remediation tools, bundled docs resources, and setup prompts. Write tools require `confirm:true` plus a reason.
27
+ The MCP server reuses `.securenow/credentials.json` and exposes apps, traces, logs, firewall, IP intelligence, forensics, notifications, remediation tools, alert-rule review/tuning tools, bundled docs resources, and setup prompts. Write tools require `confirm:true` plus a reason.
28
28
 
29
29
  ### Authenticate
30
30
 
@@ -153,16 +153,17 @@ securenow apps scan [--yes] # scan all app domains for new subd
153
153
 
154
154
  Manage the firewall API key stored in the credentials file. Since v7.5.1 the login flow writes `snk_live_...` keys to `.securenow/credentials.json` by default, so no env var is required for local dev.
155
155
 
156
- ```bash
157
- securenow api-key set snk_live_xxxxxxxxxx # save to project ./.securenow/ (default)
158
- securenow api-key set snk_live_xxx --global # save to ~/.securenow/ instead
156
+ ```bash
157
+ securenow api-key create --name "CLI firewall" # mint + save a firewall key with your logged-in session
158
+ securenow api-key set snk_live_xxxxxxxxxx # save to project ./.securenow/ (default)
159
+ securenow api-key set snk_live_xxx --global # save to ~/.securenow/ instead
159
160
  securenow api-key show # print the masked current key + its source
160
161
  securenow api-key clear # remove just the API key (keeps session/app)
161
162
  securenow api-key clear --global # same, but from the global file
162
163
  securenow credentials runtime --env production # write .securenow/credentials.production.json for production secret-file deploys
163
164
  ```
164
165
 
165
- The key must start with `snk_live_`. `securenow login` with firewall enabled writes the key automatically; use `api-key set` when you already have a key from the dashboard, or to rotate it later.
166
+ The key must start with `snk_live_`. `securenow login` with firewall enabled writes the key automatically; use `api-key create` when you have an account session but no runtime key yet, or `api-key set` when you already have a key from the dashboard.
166
167
 
167
168
  ### Init — Project Setup
168
169
 
@@ -239,7 +240,7 @@ MCP parity:
239
240
  - `securenow_human_case_action_update`
240
241
  - prompts: `investigate_human_action_row`, `work_human_actions`
241
242
 
242
- Write tools still require `confirm:true` plus a reason. False positives should stay restrictive to the app, alert rule, path, method/status, user-agent, body pattern, or other exact evidence the AI report supports.
243
+ Write tools still require `confirm:true` plus a reason. False positives should stay restrictive to the app, alert rule, path, method/status, user-agent, body pattern, or other exact evidence the AI report supports. Notification IP investigations can include `metadata.matchedSubdetectors`; prefer that exact field over inferring which subdetector fired from the title or sample body.
243
244
 
244
245
  ### Alerts
245
246
 
@@ -250,10 +251,22 @@ securenow alerts rules show <id> # one rule; JSON: --json
250
251
  securenow alerts rules update <id> --applications-all # all current & future apps
251
252
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
252
253
  securenow alerts rules test <id> --mode dry_run --wait # validate a rule query
254
+ securenow alerts rules dry-run-query <id> --sql @candidate.sql --app <key> --wait
255
+ securenow alerts rules tune-query <id> --sql @candidate.sql --reason "Preserve exploit detector, remove benign broad match" --apply-globally --yes
253
256
  securenow alerts rules exclusions <id> list # embedded rule exclusions
254
257
  securenow alerts channels # list alert channels (Slack, email, etc.)
255
- securenow alerts history [--limit N] # past triggered alerts
256
- ```
258
+ securenow alerts history [--limit N] # past triggered alerts
259
+ ```
260
+
261
+ MCP parity for noisy alert-rule reviews:
262
+
263
+ - `securenow_alert_rule_get`
264
+ - `securenow_alert_rule_candidate_test` dry-runs a full candidate SQL query without saving it.
265
+ - `securenow_alert_rule_test_result` polls the dry-run.
266
+ - `securenow_alert_rule_query_update` updates the shared public query mapping behind a system rule for all customer copies. It is admin-only, requires `confirm:true`, `applyGlobally:true`, `reason`, and SQL that keeps `__USER_APP_KEYS__` tenant scoping.
267
+ - `securenow_alert_rule_exclusion_add` remains the last-resort customer-specific path; it supports restrictive conditions plus `matchMode` and should not be used to hide a generic system-rule bug.
268
+
269
+ For system-rule tuning, dry-run the candidate SQL first, then save with `tune-query`/`securenow_alert_rule_query_update` only when the guard preserves attack detection. Good guards add exact exploit tokens, dangerous schemes, matched subdetectors, sensitive path/status evidence, malicious user agents, or repeat thresholds; bad guards simply suppress a noisy path.
257
270
 
258
271
  ---
259
272
 
@@ -322,6 +335,21 @@ work:
322
335
  - `securenow_blocklist_pending_bulk_reject`
323
336
  - prompt: `cleanup_legacy_pending_blocks`
324
337
 
338
+ ### Rate Limits - Soft Remediation
339
+
340
+ ```bash
341
+ securenow ratelimit list --env production
342
+ securenow ratelimit add 203.0.113.42 --route /api/login --limit 2 --window 1m --duration 24h
343
+ securenow ratelimit parse "rate limit 203.0.113.42 on POST /api/login to 2 attempts per minute"
344
+ securenow ratelimit from-text "rate limit /api/login for this IP to 2 attempts per minute for 24h" --yes
345
+ securenow ratelimit test 203.0.113.42 --path /api/login --method POST
346
+ ```
347
+
348
+ MCP parity:
349
+
350
+ - `securenow_rate_limit_parse`
351
+ - `securenow_rate_limit_create_from_text`
352
+
325
353
  ### Automation Rules
326
354
 
327
355
  ```bash
package/cli/apiKey.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { api, requireAuth } = require('./client');
3
4
  const config = require('./config');
4
5
  const ui = require('./ui');
5
6
 
@@ -8,6 +9,47 @@ function maskKey(key) {
8
9
  return `${key.slice(0, 12)}••••••${key.slice(-4)}`;
9
10
  }
10
11
 
12
+ async function create(args, flags) {
13
+ requireAuth();
14
+
15
+ const name = flags.name || args.join(' ').trim() || 'CLI firewall';
16
+ const preset = flags.preset || 'firewall';
17
+ const local = flags.global ? false : true;
18
+
19
+ const s = ui.spinner(`Creating ${preset} API key`);
20
+ try {
21
+ const result = await api.post('/api-keys', { name, preset });
22
+ const key = result && result.key;
23
+ if (!key || !key.startsWith('snk_live_')) {
24
+ throw new Error('API did not return a valid firewall key');
25
+ }
26
+
27
+ config.setApiKey(key, { local });
28
+ if (local) config.ensureLocalGitignore();
29
+ s.stop('API key created and saved');
30
+
31
+ if (flags.json) {
32
+ ui.json({
33
+ id: result.apiKey?._id || result.apiKey?.id || null,
34
+ name: result.apiKey?.name || name,
35
+ preset,
36
+ key: maskKey(key),
37
+ savedTo: local ? 'project .securenow/credentials.json' : '~/.securenow/credentials.json',
38
+ });
39
+ return;
40
+ }
41
+
42
+ ui.success(`API key saved (${maskKey(key)})`);
43
+ ui.info(local
44
+ ? 'Stored in project .securenow/credentials.json (local)'
45
+ : 'Stored in ~/.securenow/credentials.json (global)');
46
+ ui.info('The plaintext key is not printed. The SDK will read it from the credentials file.');
47
+ } catch (err) {
48
+ s.fail('Failed to create API key');
49
+ throw err;
50
+ }
51
+ }
52
+
11
53
  async function set(args, flags) {
12
54
  const key = args[0];
13
55
  if (!key) {
@@ -52,4 +94,4 @@ async function show() {
52
94
  ui.info(`Source: ${config.getAuthSource()}`);
53
95
  }
54
96
 
55
- module.exports = { set, clear, show };
97
+ module.exports = { create, set, clear, show };
package/cli/config.js CHANGED
@@ -47,6 +47,12 @@ function saveJSON(filepath, data) {
47
47
  }
48
48
  }
49
49
 
50
+ function credentialsForWrite(targetFile) {
51
+ const inherited = loadCredentials() || {};
52
+ const existing = loadJSON(targetFile);
53
+ return appConfig.mergeCredentials(inherited, existing) || {};
54
+ }
55
+
50
56
  function credentialsFileForLocal(local) {
51
57
  return local ? LOCAL_CREDENTIALS_FILE : CREDENTIALS_FILE;
52
58
  }
@@ -113,7 +119,7 @@ function withOnboardingFirewallEnabled(creds) {
113
119
  function ensureCredentialDefaults({ local, enableFirewall = false } = {}) {
114
120
  const useLocal = local === true || (local == null && hasLocalCredentials());
115
121
  const targetFile = credentialsFileForLocal(useLocal);
116
- const existing = loadJSON(targetFile);
122
+ const existing = useLocal ? credentialsForWrite(targetFile) : loadJSON(targetFile);
117
123
  const payload = enableFirewall
118
124
  ? withOnboardingFirewallEnabled(existing || {})
119
125
  : appConfig.withCredentialDefaults(existing || {}) || {};
@@ -168,7 +174,7 @@ function getApp() {
168
174
  function setApiKey(apiKey, { local } = {}) {
169
175
  const useLocal = local === true || (local == null && hasLocalCredentials());
170
176
  const targetFile = credentialsFileForLocal(useLocal);
171
- const existing = loadJSON(targetFile);
177
+ const existing = useLocal ? credentialsForWrite(targetFile) : loadJSON(targetFile);
172
178
  saveJSON(targetFile, withOnboardingFirewallEnabled({ ...existing, apiKey }));
173
179
  }
174
180
 
@@ -189,7 +195,7 @@ function getApiKey() {
189
195
  function setApp(app, { local } = {}) {
190
196
  const useLocal = local === true || (local == null && hasLocalCredentials());
191
197
  const targetFile = credentialsFileForLocal(useLocal);
192
- const existing = loadJSON(targetFile);
198
+ const existing = useLocal ? credentialsForWrite(targetFile) : loadJSON(targetFile);
193
199
  saveJSON(targetFile, appConfig.withCredentialDefaults({
194
200
  ...existing,
195
201
  app: {
package/cli/rateLimits.js CHANGED
@@ -47,6 +47,54 @@ function describeTarget(rule) {
47
47
  return parts.join(' ') || 'all traffic';
48
48
  }
49
49
 
50
+ function naturalText(args, flags) {
51
+ return String(flags.text || flags.prompt || flags.query || args.join(' ') || '').trim();
52
+ }
53
+
54
+ function draftFromFlags(flags) {
55
+ const draft = {};
56
+ const appKey = resolveApp(flags);
57
+ const environment = resolveEnvironment(flags, null);
58
+ if (appKey) draft.appKey = appKey;
59
+ if (environment) draft.environment = environment;
60
+ if (flags.ip) draft.ip = flags.ip;
61
+ if (flags.route || flags.path || flags.pattern) draft.pathPattern = flags.route || flags.path || flags.pattern;
62
+ if (flags.mode || flags['path-mode']) draft.pathMatchMode = flags.mode || flags['path-mode'];
63
+ if (flags.method) draft.method = flags.method;
64
+ if (flags['key-by'] || flags.keyBy) draft.keyBy = flags['key-by'] || flags.keyBy;
65
+ if (flags.limit) draft.limit = Number(flags.limit);
66
+ if (flags.window || flags['window-seconds']) draft.windowSeconds = parseWindow(flags.window || flags['window-seconds']);
67
+ if (flags.duration) draft.duration = flags.duration;
68
+ if (flags.reason) draft.reason = flags.reason;
69
+ if (flags.name) draft.name = flags.name;
70
+ return draft;
71
+ }
72
+
73
+ function printParsedDraft(data) {
74
+ const draft = data.draft || {};
75
+ ui.keyValue([
76
+ ['Name', draft.name || '-'],
77
+ ['Target', describeTarget(draft)],
78
+ ['Limit', `${draft.limit || '-'} / ${draft.windowSeconds || '-'}s`],
79
+ ['Key by', draft.keyBy || 'ip'],
80
+ ['App', draft.appKey || 'all'],
81
+ ['Environment', draft.environment || 'all'],
82
+ ['Duration', draft.duration || 'none'],
83
+ ['Status', draft.status || 'active'],
84
+ ['Reason', draft.reason || '-'],
85
+ ['Provider', data.provider || '-'],
86
+ ['Confidence', data.confidence != null ? String(data.confidence) : '-'],
87
+ ]);
88
+ if (Array.isArray(data.missingFields) && data.missingFields.length) {
89
+ console.log('');
90
+ console.log(` ${ui.c.yellow('Missing:')} ${data.missingFields.join(', ')}`);
91
+ }
92
+ if (Array.isArray(data.warnings) && data.warnings.length) {
93
+ console.log('');
94
+ for (const warning of data.warnings.slice(0, 5)) console.log(` ${ui.c.yellow('!')} ${warning}`);
95
+ }
96
+ }
97
+
50
98
  async function list(args, flags) {
51
99
  requireAuth();
52
100
  const s = ui.spinner('Fetching rate limits');
@@ -76,6 +124,58 @@ async function list(args, flags) {
76
124
  }
77
125
  }
78
126
 
127
+ async function parseText(args, flags) {
128
+ requireAuth();
129
+ const text = naturalText(args, flags);
130
+ if (!text) {
131
+ ui.error('Usage: securenow ratelimit parse "rate limit /api/login to 2 attempts per minute"');
132
+ process.exit(1);
133
+ }
134
+
135
+ const s = ui.spinner('Parsing natural-language rate limit');
136
+ try {
137
+ const data = await api.post('/rate-limits/parse', { text, draft: draftFromFlags(flags) });
138
+ s.stop('Rate-limit draft parsed');
139
+ if (flags.json) { ui.json(data); return; }
140
+ console.log('');
141
+ printParsedDraft(data);
142
+ console.log('');
143
+ console.log(` ${ui.c.dim('Create it with:')} securenow ratelimit from-text ${JSON.stringify(text)} --yes`);
144
+ console.log('');
145
+ } catch (err) {
146
+ s.fail('Failed to parse rate limit');
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ async function fromText(args, flags) {
152
+ requireAuth();
153
+ const text = naturalText(args, flags);
154
+ if (!text) {
155
+ ui.error('Usage: securenow ratelimit from-text "rate limit /api/login to 2 attempts per minute" --yes');
156
+ process.exit(1);
157
+ }
158
+ if (!flags.yes && !flags.force) {
159
+ const ok = await ui.confirm('Create this rate-limit rule from natural language?');
160
+ if (!ok) { ui.info('Cancelled'); return; }
161
+ }
162
+
163
+ const s = ui.spinner('Creating rate limit from natural language');
164
+ try {
165
+ const data = await api.post('/rate-limits/from-text', { text, draft: draftFromFlags(flags) });
166
+ s.stop('Rate limit created');
167
+ if (flags.json) { ui.json(data); return; }
168
+ const r = data.rateLimit;
169
+ console.log('');
170
+ console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
171
+ console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
172
+ console.log('');
173
+ } catch (err) {
174
+ s.fail('Failed to create rate limit from text');
175
+ throw err;
176
+ }
177
+ }
178
+
79
179
  async function show(args, flags) {
80
180
  requireAuth();
81
181
  const id = args[0];
@@ -242,4 +342,4 @@ async function test(args, flags) {
242
342
  }
243
343
  }
244
344
 
245
- module.exports = { list, show, add, enable, disable, remove, test };
345
+ module.exports = { list, show, add, parseText, fromText, enable, disable, remove, test };
package/cli/security.js CHANGED
@@ -1,8 +1,9 @@
1
- 'use strict';
2
-
3
- const { api, requireAuth } = require('./client');
4
- const config = require('./config');
5
- const ui = require('./ui');
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { api, requireAuth } = require('./client');
5
+ const config = require('./config');
6
+ const ui = require('./ui');
6
7
 
7
8
  function resolveApp(flags) {
8
9
  return flags.app || config.getDefaultApp();
@@ -44,6 +45,12 @@ async function alertRulesRoute(args, flags) {
44
45
  if (sub === 'test') {
45
46
  return alertRuleTest(args.slice(1), flags);
46
47
  }
48
+ if (sub === 'dry-run-query' || sub === 'candidate-test') {
49
+ return alertRuleCandidateTest(args.slice(1), flags);
50
+ }
51
+ if (sub === 'tune-query' || sub === 'query-update') {
52
+ return alertRuleQueryUpdate(args.slice(1), flags);
53
+ }
47
54
  if (sub === 'exclusions') {
48
55
  return alertRuleExclusions(args.slice(1), flags);
49
56
  }
@@ -180,6 +187,16 @@ async function alertRuleUpdate(args, flags) {
180
187
  }
181
188
  }
182
189
 
190
+ function readSqlArg(flags) {
191
+ const raw = flags.sql || flags.query || flags.file;
192
+ if (!raw) return null;
193
+ if (flags.file) return fs.readFileSync(flags.file, 'utf8');
194
+ const text = String(raw);
195
+ if (text === '-') return fs.readFileSync(0, 'utf8');
196
+ if (text.startsWith('@')) return fs.readFileSync(text.slice(1), 'utf8');
197
+ return text;
198
+ }
199
+
183
200
  async function alertRuleTest(args, flags) {
184
201
  requireAuth();
185
202
  const id = args[0];
@@ -219,6 +236,100 @@ async function alertRuleTest(args, flags) {
219
236
  }
220
237
  }
221
238
 
239
+ async function alertRuleCandidateTest(args, flags) {
240
+ requireAuth();
241
+ const id = args[0];
242
+ const candidateSqlQuery = readSqlArg(flags);
243
+ if (!id || !candidateSqlQuery) {
244
+ ui.error('Usage: securenow alerts rules dry-run-query <rule-id> --sql <sql|@file|-> [--app <key>] [--wait]');
245
+ process.exit(1);
246
+ }
247
+
248
+ const body = { mode: 'dry_run', candidateSqlQuery };
249
+ if (flags.app) body.applicationKey = flags.app;
250
+
251
+ const s = ui.spinner('Starting candidate SQL dry-run');
252
+ try {
253
+ let data = await api.post(`/alert-rules/${id}/test`, body);
254
+ if (flags.wait && data.testId) {
255
+ s.update('Waiting for candidate SQL dry-run results');
256
+ for (let i = 0; i < 40; i++) {
257
+ await new Promise((resolve) => setTimeout(resolve, 2000));
258
+ data = await api.get(`/alert-rules/${id}/test/${data.testId}`);
259
+ if (['complete', 'failed'].includes(data.status)) break;
260
+ }
261
+ }
262
+ s.stop('Candidate dry-run ready');
263
+ if (flags.json) { ui.json(data); return; }
264
+ console.log('');
265
+ ui.keyValue([
266
+ ['Test ID', data.testId || '-'],
267
+ ['Status', data.status || '-'],
268
+ ['Candidate', data.candidate ? 'yes' : 'no'],
269
+ ['Current SQL hash', data.currentSqlHash || '-'],
270
+ ['Candidate SQL hash', data.candidateSqlHash || '-'],
271
+ ['Result count', String(data.resultCount ?? '-')],
272
+ ['Error', data.error || '-'],
273
+ ]);
274
+ console.log('');
275
+ } catch (err) {
276
+ s.fail('Failed to dry-run candidate SQL');
277
+ throw err;
278
+ }
279
+ }
280
+
281
+ async function alertRuleQueryUpdate(args, flags) {
282
+ requireAuth();
283
+ const id = args[0];
284
+ const sqlQuery = readSqlArg(flags);
285
+ const reason = flags.reason;
286
+ if (!id || !sqlQuery || !reason) {
287
+ ui.error('Usage: securenow alerts rules tune-query <rule-id> --sql <sql|@file|-> --reason "..." --apply-globally --yes');
288
+ process.exit(1);
289
+ }
290
+ if (!flags['apply-globally'] && flags.applyGlobally !== true) {
291
+ ui.error('System query tuning requires --apply-globally.');
292
+ process.exit(1);
293
+ }
294
+
295
+ if (!flags.force && !flags.yes) {
296
+ const ok = await ui.confirm('Update the shared system query mapping for all customer copies?');
297
+ if (!ok) {
298
+ ui.info('Cancelled');
299
+ return;
300
+ }
301
+ }
302
+
303
+ const body = {
304
+ sqlQuery,
305
+ reason,
306
+ applyGlobally: true,
307
+ reactivatePausedCopies: !!(flags['reactivate-paused'] || flags.reactivatePausedCopies),
308
+ };
309
+ if (flags['expected-hash']) body.expectedCurrentSqlHash = flags['expected-hash'];
310
+ if (flags.notification) body.reviewNotificationId = flags.notification;
311
+ if (flags.note) body.reviewNote = flags.note;
312
+
313
+ const s = ui.spinner('Updating system query mapping');
314
+ try {
315
+ const data = await api.put(`/alert-rules/${id}/query-mapping`, body);
316
+ s.stop('System query mapping updated');
317
+ if (flags.json) { ui.json(data); return; }
318
+ console.log('');
319
+ ui.keyValue([
320
+ ['Query mapping', data.queryMapping?.name || data.queryMapping?.id || '-'],
321
+ ['Previous SQL hash', data.queryMapping?.previousSqlHash || '-'],
322
+ ['New SQL hash', data.queryMapping?.newSqlHash || '-'],
323
+ ['Affected system rules', String(data.affectedSystemRules ?? '-')],
324
+ ['Reactivated paused copies', data.reactivatedPausedCopies ? 'yes' : 'no'],
325
+ ]);
326
+ console.log('');
327
+ } catch (err) {
328
+ s.fail('Failed to update system query mapping');
329
+ throw err;
330
+ }
331
+ }
332
+
222
333
  async function alertRuleExclusions(args, flags) {
223
334
  requireAuth();
224
335
  const id = args[0];
package/cli.js CHANGED
@@ -77,11 +77,21 @@ const COMMANDS = {
77
77
  },
78
78
  'api-key': {
79
79
  desc: 'Manage the firewall API key stored in .securenow/credentials.json',
80
- usage: 'securenow api-key <subcommand> [options]',
81
- sub: {
82
- set: {
83
- desc: 'Save an API key (snk_live_...) to the credentials file',
84
- usage: 'securenow api-key set <snk_live_...> [--global]',
80
+ usage: 'securenow api-key <subcommand> [options]',
81
+ sub: {
82
+ create: {
83
+ desc: 'Create a firewall API key with your session token and save it',
84
+ usage: 'securenow api-key create [name] [--name <name>] [--preset firewall] [--global]',
85
+ flags: {
86
+ name: 'Human-readable key name',
87
+ preset: 'API key preset to create (default: firewall)',
88
+ global: 'Save to ~/.securenow/ instead of project-local',
89
+ },
90
+ run: (a, f) => require('./cli/apiKey').create(a, f),
91
+ },
92
+ set: {
93
+ desc: 'Save an API key (snk_live_...) to the credentials file',
94
+ usage: 'securenow api-key set <snk_live_...> [--global]',
85
95
  flags: { global: 'Save to ~/.securenow/ instead of project-local' },
86
96
  run: (a, f) => require('./cli/apiKey').set(a, f),
87
97
  },
@@ -208,16 +218,30 @@ const COMMANDS = {
208
218
  desc: 'Manage alerting',
209
219
  usage: 'securenow alerts <subcommand> [options]',
210
220
  sub: {
211
- rules: {
212
- desc: 'List, show, or update alert rules',
213
- flags: {
214
- json: 'Output as JSON',
215
- 'applications-all': 'With update: scope rule to all apps',
216
- 'no-applications-all': 'With update: scope to explicit --apps list',
217
- apps: 'Comma-separated app keys (with update)',
218
- },
219
- run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
220
- },
221
+ rules: {
222
+ desc: 'List, show, update, test, or tune alert rules',
223
+ flags: {
224
+ json: 'Output as JSON',
225
+ 'applications-all': 'With update: scope rule to all apps',
226
+ 'no-applications-all': 'With update: scope to explicit --apps list',
227
+ apps: 'Comma-separated app keys (with update)',
228
+ app: 'Application key for rule tests',
229
+ mode: 'Rule test mode: dry_run or live',
230
+ wait: 'Wait for rule test completion',
231
+ sql: 'Candidate/replacement SQL, @file, or - for stdin',
232
+ query: 'Alias for --sql',
233
+ file: 'Read candidate/replacement SQL from a file',
234
+ reason: 'Audit reason for a write',
235
+ 'apply-globally': 'Required for system query tuning',
236
+ 'reactivate-paused': 'Reactivate paused system copies after tuning',
237
+ 'expected-hash': 'Expected current SQL SHA-256 hash',
238
+ notification: 'Review notification id tied to tuning',
239
+ note: 'Review note tied to tuning',
240
+ yes: 'Confirm write prompts',
241
+ force: 'Confirm write prompts',
242
+ },
243
+ run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
244
+ },
221
245
  channels: { desc: 'List alert channels', run: (a, f) => require('./cli/security').alertChannelsList(a, f) },
222
246
  history: { desc: 'View alert history', flags: { limit: 'Max results' }, run: (a, f) => require('./cli/security').alertHistoryList(a, f) },
223
247
  },
@@ -255,7 +279,7 @@ const COMMANDS = {
255
279
  },
256
280
  ratelimit: {
257
281
  desc: 'Manage soft rate-limit remediation rules',
258
- usage: 'securenow ratelimit <list|add|show|test|enable|disable|remove> [options]',
282
+ usage: 'securenow ratelimit <list|add|parse|from-text|show|test|enable|disable|remove> [options]',
259
283
  flags: {
260
284
  app: 'Scope to app key (defaults to logged-in app)',
261
285
  env: 'Scope to environment (default for create/test: production)',
@@ -269,10 +293,13 @@ const COMMANDS = {
269
293
  mode: 'Path mode: exact, prefix, or regex',
270
294
  method: 'HTTP method, or ALL',
271
295
  reason: 'Audit reason',
296
+ text: 'Natural-language rate-limit request',
272
297
  },
273
298
  sub: {
274
299
  list: { desc: 'List rate-limit remediation rules', run: (a, f) => require('./cli/rateLimits').list(a, f) },
275
300
  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) },
301
+ 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) },
302
+ '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) },
276
303
  show: { desc: 'Show one rate-limit rule', usage: 'securenow ratelimit show <id>', run: (a, f) => require('./cli/rateLimits').show(a, f) },
277
304
  test: { desc: 'Check whether a request would match rate-limit rules', usage: 'securenow ratelimit test <ip> --path /api/login --method POST', run: (a, f) => require('./cli/rateLimits').test(a, f) },
278
305
  enable: { desc: 'Enable a rate-limit rule', usage: 'securenow ratelimit enable <id>', run: (a, f) => require('./cli/rateLimits').enable(a, f) },
@@ -283,10 +310,27 @@ const COMMANDS = {
283
310
  },
284
311
  'rate-limit': {
285
312
  desc: 'Alias for ratelimit',
286
- usage: 'securenow rate-limit <list|add|show|test|enable|disable|remove> [options]',
313
+ usage: 'securenow rate-limit <list|add|parse|from-text|show|test|enable|disable|remove> [options]',
314
+ flags: {
315
+ app: 'Scope to app key (defaults to logged-in app)',
316
+ env: 'Scope to environment (default for create/test: production)',
317
+ environment: 'Alias for --env',
318
+ json: 'Output as JSON',
319
+ limit: 'Allowed requests per window',
320
+ window: 'Window size, e.g. 30s, 1m, 1h',
321
+ duration: 'Expiry, e.g. 24h or 7d',
322
+ route: 'Path pattern such as /api/login',
323
+ path: 'Alias for --route',
324
+ mode: 'Path mode: exact, prefix, or regex',
325
+ method: 'HTTP method, or ALL',
326
+ reason: 'Audit reason',
327
+ text: 'Natural-language rate-limit request',
328
+ },
287
329
  sub: {
288
330
  list: { desc: 'List rate-limit remediation rules', run: (a, f) => require('./cli/rateLimits').list(a, f) },
289
331
  add: { desc: 'Create a rate-limit rule', usage: 'securenow rate-limit add [ip] --route /api/login --limit 5 --window 1m --duration 24h', run: (a, f) => require('./cli/rateLimits').add(a, f) },
332
+ parse: { desc: 'Parse natural language into a rate-limit draft', usage: 'securenow rate-limit parse "rate limit /api/login to 2 attempts per minute"', run: (a, f) => require('./cli/rateLimits').parseText(a, f) },
333
+ 'from-text': { desc: 'Create a rate-limit rule from natural language', usage: 'securenow rate-limit from-text "rate limit /api/login to 2 attempts per minute" --yes', run: (a, f) => require('./cli/rateLimits').fromText(a, f) },
290
334
  show: { desc: 'Show one rate-limit rule', usage: 'securenow rate-limit show <id>', run: (a, f) => require('./cli/rateLimits').show(a, f) },
291
335
  test: { desc: 'Check whether a request would match rate-limit rules', usage: 'securenow rate-limit test <ip> --path /api/login --method POST', run: (a, f) => require('./cli/rateLimits').test(a, f) },
292
336
  enable: { desc: 'Enable a rate-limit rule', usage: 'securenow rate-limit enable <id>', run: (a, f) => require('./cli/rateLimits').enable(a, f) },
package/mcp/catalog.js CHANGED
@@ -169,7 +169,7 @@ const decisionReportInput = {
169
169
  description: 'Structured audit report explaining the decision, evidence reviewed, trace IDs, missing proof, and recommendations.',
170
170
  },
171
171
  decisionSummary: string('Short decision summary to record on the IP/case history.'),
172
- outcome: string('Decision outcome: blocked, false_positive, clean, deferred, case_action, rule_tuning, new_alert_rule, ambiguous, skipped, or other.'),
172
+ outcome: string('Decision outcome: blocked, rate_limited, false_positive, clean, deferred, case_action, rule_tuning, new_alert_rule, ambiguous, skipped, or other.'),
173
173
  evidence: arrayOfStrings('Evidence strings that support the decision.'),
174
174
  reviewedHistory: arrayOfStrings('History/proofs reviewed before deciding.'),
175
175
  traceIds: arrayOfStrings('Trace IDs reviewed for this decision.'),
@@ -513,7 +513,7 @@ const TOOLS = [
513
513
  {
514
514
  name: 'securenow_human_action_decision_report_add',
515
515
  title: 'Record Human Action Decision Report',
516
- description: 'Attach a structured analyst/MCP decision report to one Requires Human IP row without changing its status. Use for skipped, ambiguous, rule-tuning-needed, or already-handled rows. Write action; requires confirmation.',
516
+ description: 'Attach a structured analyst/MCP decision report to one Requires Human IP row without changing its status. Use for rate-limited, skipped, ambiguous, rule-tuning-needed, or already-handled rows. Write action; requires confirmation.',
517
517
  scope: 'notifications:write',
518
518
  readOnly: false,
519
519
  confirm: true,
@@ -802,6 +802,36 @@ const TOOLS = [
802
802
  ...confirmSchema,
803
803
  }, ['id', 'confirm', 'reason']),
804
804
  },
805
+ {
806
+ name: 'securenow_rate_limit_parse',
807
+ title: 'Parse Rate Limit Text',
808
+ description: 'Use SecureNow AI to convert natural language into a rate-limit remediation draft without creating it.',
809
+ scope: 'rate_limits:read',
810
+ readOnly: true,
811
+ method: 'POST',
812
+ endpoint: '/rate-limits/parse',
813
+ bodyFields: ['text', 'draft'],
814
+ inputSchema: objectSchema({
815
+ text: string('Natural-language rate-limit request, e.g. "rate limit /api/login to 2 attempts per minute for this IP".'),
816
+ draft: { type: 'object', additionalProperties: true, description: 'Optional existing draft fields to preserve or resolve phrases such as "this IP".' },
817
+ }, ['text']),
818
+ },
819
+ {
820
+ name: 'securenow_rate_limit_create_from_text',
821
+ title: 'Create Rate Limit From Text',
822
+ description: 'Create a rate-limit remediation rule from natural language. Write action; requires confirmation.',
823
+ scope: 'rate_limits:write',
824
+ readOnly: false,
825
+ confirm: true,
826
+ method: 'POST',
827
+ endpoint: '/rate-limits/from-text',
828
+ bodyFields: ['text', 'draft', 'reason'],
829
+ inputSchema: objectSchema({
830
+ text: string('Natural-language rate-limit request.'),
831
+ draft: { type: 'object', additionalProperties: true, description: 'Optional existing draft fields or scope hints.' },
832
+ ...confirmSchema,
833
+ }, ['text', 'confirm', 'reason']),
834
+ },
805
835
  {
806
836
  name: 'securenow_alert_rules_list',
807
837
  title: 'List Alert Rules',
@@ -852,6 +882,29 @@ const TOOLS = [
852
882
  ...confirmSchema,
853
883
  }, ['id', 'confirm', 'reason']),
854
884
  },
885
+ {
886
+ name: 'securenow_alert_rule_query_update',
887
+ title: 'Update System Alert Rule Query',
888
+ description: 'Admin-only: update the shared public query mapping behind a system alert rule for all customer copies. Write action; requires confirmation.',
889
+ scope: 'alerts:write',
890
+ readOnly: false,
891
+ confirm: true,
892
+ method: 'PUT',
893
+ endpoint: '/alert-rules/:id/query-mapping',
894
+ pathParams: ['id'],
895
+ bodyFields: ['sqlQuery', 'reason', 'expectedCurrentSqlHash', 'applyGlobally', 'reactivatePausedCopies', 'reviewNotificationId', 'reviewNote', 'source'],
896
+ fixedBody: { applyGlobally: true, source: 'mcp' },
897
+ inputSchema: objectSchema({
898
+ id: string('Alert rule id. Must be a system rule.'),
899
+ sqlQuery: string('Full replacement SQL query. System SQL must keep __USER_APP_KEYS__ for tenant scoping.'),
900
+ expectedCurrentSqlHash: string('Optional SHA-256 hash of the current SQL to prevent stale overwrites.'),
901
+ applyGlobally: boolean('Must be true. Updates the shared public query mapping used by all system rule copies.'),
902
+ reactivatePausedCopies: boolean('Whether to reactivate paused noisy copies after the query is tuned.'),
903
+ reviewNotificationId: string('Optional notification id tied to this review/tuning action.'),
904
+ reviewNote: string('Optional review note. Defaults to reason.'),
905
+ ...confirmSchema,
906
+ }, ['id', 'sqlQuery', 'confirm', 'reason']),
907
+ },
855
908
  {
856
909
  name: 'securenow_alert_rule_test',
857
910
  title: 'Test Alert Rule',
@@ -870,6 +923,25 @@ const TOOLS = [
870
923
  ...confirmSchema,
871
924
  }, ['id', 'confirm', 'reason']),
872
925
  },
926
+ {
927
+ name: 'securenow_alert_rule_candidate_test',
928
+ title: 'Dry-Run Candidate Alert Rule SQL',
929
+ description: 'Dry-run a candidate SQL/query guard for an alert rule without saving it.',
930
+ scope: 'alerts:write',
931
+ readOnly: false,
932
+ confirm: true,
933
+ method: 'POST',
934
+ endpoint: '/alert-rules/:id/test',
935
+ pathParams: ['id'],
936
+ bodyFields: ['applicationKey', 'candidateSqlQuery'],
937
+ fixedBody: { mode: 'dry_run' },
938
+ inputSchema: objectSchema({
939
+ id: string('Alert rule id.'),
940
+ applicationKey: string('Application key to test.'),
941
+ candidateSqlQuery: string('Full candidate SQL query to dry-run without saving.'),
942
+ ...confirmSchema,
943
+ }, ['id', 'candidateSqlQuery', 'confirm', 'reason']),
944
+ },
873
945
  {
874
946
  name: 'securenow_alert_rule_test_result',
875
947
  title: 'Get Alert Rule Test Result',
@@ -1386,9 +1458,11 @@ function promptMessages(name, args = {}) {
1386
1458
  `Fetch page=${page}, limit=${limit} with securenow_human_actions_list, select row ${rowNumber}, then call securenow_notifications_get and securenow_human_action_report for that notificationId and IP.`,
1387
1459
  'Read the AI report, finalDecision, investigation steps, findings, proofs, metadata paths/user agents/status codes, and trace IDs.',
1388
1460
  'Open trace evidence with securenow_traces_show and correlated logs with securenow_logs_for_trace when trace IDs are available.',
1389
- 'Return one clear outcome: Block IP, False Positive, Rule Tuning Needed, or Ambiguous. If evidence is ambiguous, stop and explain what is missing.',
1461
+ 'Return one clear outcome: Block IP, Rate Limit, False Positive, Rule Tuning Needed, or Ambiguous. If evidence is ambiguous, stop and explain what is missing.',
1462
+ 'Use rate limiting only as temporary soft remediation for repeated route-specific abuse such as login brute force, credential stuffing, scraping/API bursts, enumeration, recon/probing, or repeated noisy payloads where risk/impact evidence is below the block threshold.',
1463
+ 'Do not rate-limit instead of blocking when there is confirmed exploit success, token/data exposure, SSRF reachability, file read, RCE, persistence, malware/C2, or riskScore >= 85 with high-confidence malicious evidence. Do not rate-limit benign false positives, trusted monitors, app-server/proxy attribution problems, isolated one-off requests, or broad noisy rules that need alert tuning.',
1390
1464
  confirmWrites
1391
- ? 'The user requested execution. If evidence supports the decision, call securenow_human_action_block or securenow_human_action_false_positive with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant. If you skip/mark ambiguous but still need to record the audit trail, call securenow_human_action_decision_report_add.'
1465
+ ? 'The user requested execution. If evidence supports the decision, call securenow_human_action_block, securenow_rate_limit_create_from_text plus securenow_human_action_decision_report_add(outcome=rate_limited), or securenow_human_action_false_positive with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant. If you skip/mark ambiguous but still need to record the audit trail, call securenow_human_action_decision_report_add.'
1392
1466
  : 'Do not execute write tools yet. Prepare the recommended decision and exact tool call the user can approve.',
1393
1467
  'If many IPs share the same benign path/status/user-agent pattern, recommend tightening the alert rule with a precise guard instead of reviewing each IP as malicious.',
1394
1468
  'False positives must be narrow: app + alert rule + path + method/status/user-agent/body evidence where possible. Never globally trust an IP by default.',
@@ -1410,12 +1484,14 @@ function promptMessages(name, args = {}) {
1410
1484
  'Work my SecureNow Requires Human queue like a senior security analyst using the MCP tools.',
1411
1485
  `Review up to ${limit} row(s), most urgent first.${args.search ? ` Search filter: ${args.search}.` : ''}`,
1412
1486
  'Start with securenow_human_actions_list. For each row, call securenow_notifications_get and securenow_human_action_report, inspect the AI report/investigation steps/proofs/trace IDs, and fetch trace/log evidence where useful.',
1413
- 'For each row choose exactly one outcome: Block IP, False Positive, Rule Tuning Needed, or Skip because evidence is insufficient. Explain skipped rows.',
1487
+ 'For each row choose exactly one outcome: Block IP, Rate Limit, False Positive, Rule Tuning Needed, or Skip because evidence is insufficient. Explain skipped rows.',
1488
+ 'Use Rate Limit only for repeated route-specific abuse where temporary friction is safer than blocking: login brute force/credential stuffing, password reset/account enumeration, scraping/API bursts, path or ID enumeration, recon/probing, or repeated noisy payloads without confirmed exploit success.',
1489
+ 'Do not rate-limit confirmed high-risk attacks that should be blocked, benign traffic that should be false-positive scoped, broad noisy rules that need tuning, app-server/proxy attribution problems, or isolated one-off requests.',
1414
1490
  confirmWrites
1415
1491
  ? 'The user requested execution. For supported decisions, call the correct write tool with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant, then continue. For skipped/ambiguous/rule-tuning-needed rows that should be auditable without changing IP status, use securenow_human_action_decision_report_add.'
1416
1492
  : 'Do not execute write tools yet. Produce a row-by-row action plan and exact MCP write calls for user approval.',
1417
- 'For block decisions, use securenow_human_action_block. For false positives, use securenow_human_action_false_positive with restrictive conditions. For case-level tune_rule/create_exclusion rows, inspect securenow_notifications_get and then use securenow_human_case_action_update only when the action is safe to approve/reject.',
1418
- 'End with counts: handled, proposed block, proposed false positive, rule tuning needed, skipped, still waiting.',
1493
+ 'For block decisions, use securenow_human_action_block. For rate-limit decisions, use securenow_rate_limit_create_from_text and then securenow_human_action_decision_report_add with outcome=rate_limited. For false positives, use securenow_human_action_false_positive with restrictive conditions. For case-level tune_rule/create_exclusion rows, inspect securenow_notifications_get and then use securenow_human_case_action_update only when the action is safe to approve/reject.',
1494
+ 'End with counts: handled, proposed block, rate limits created/proposed, proposed false positive, rule tuning needed, skipped, still waiting.',
1419
1495
  ].join('\n'),
1420
1496
  },
1421
1497
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "7.7.9",
3
+ "version": "7.7.11",
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",
@@ -79,6 +79,10 @@
79
79
  "./firewall": {
80
80
  "default": "./firewall.js"
81
81
  },
82
+ "./rate-limits": {
83
+ "types": "./rate-limits.d.ts",
84
+ "default": "./rate-limits.js"
85
+ },
82
86
  "./firewall-only": {
83
87
  "default": "./firewall-only.js"
84
88
  },
@@ -128,6 +132,8 @@
128
132
  "resolve-ip.js",
129
133
  "cidr.js",
130
134
  "firewall.js",
135
+ "rate-limits.js",
136
+ "rate-limits.d.ts",
131
137
  "firewall-only.js",
132
138
  "firewall-tcp.js",
133
139
  "firewall-iptables.js",
@@ -0,0 +1,86 @@
1
+ export interface RateLimitDraft {
2
+ name?: string;
3
+ appKey?: string;
4
+ applicationKey?: string;
5
+ environment?: string;
6
+ ip?: string;
7
+ pathPattern?: string;
8
+ path?: string;
9
+ route?: string;
10
+ pathMatchMode?: 'exact' | 'prefix' | 'regex';
11
+ method?: 'ALL' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
12
+ keyBy?: 'ip' | 'global';
13
+ limit?: number;
14
+ windowSeconds?: number;
15
+ duration?: string;
16
+ reason?: string;
17
+ status?: 'active' | 'disabled';
18
+ }
19
+
20
+ export interface RateLimitAuthOptions {
21
+ token?: string;
22
+ apiKey?: string;
23
+ authToken?: string;
24
+ }
25
+
26
+ export interface RateLimitTextOptions extends RateLimitAuthOptions {
27
+ draft?: RateLimitDraft;
28
+ reason?: string;
29
+ }
30
+
31
+ export interface ParsedRateLimitText {
32
+ success: boolean;
33
+ draft: RateLimitDraft;
34
+ payload: Record<string, unknown>;
35
+ confidence: number;
36
+ missingFields: string[];
37
+ warnings: string[];
38
+ explanation?: string;
39
+ provider: 'anthropic' | 'local-fallback' | string;
40
+ }
41
+
42
+ export interface RateLimitRule {
43
+ id?: string;
44
+ _id?: string;
45
+ applicationKey?: string | null;
46
+ environment?: string | null;
47
+ name?: string;
48
+ ip?: string;
49
+ method?: string;
50
+ pathPattern?: string;
51
+ pathMatchMode?: string;
52
+ keyBy?: string;
53
+ limit: number;
54
+ windowSeconds: number;
55
+ reason?: string;
56
+ source?: string;
57
+ status?: string;
58
+ expiresAt?: string | null;
59
+ metadata?: Record<string, unknown>;
60
+ createdAt?: string;
61
+ updatedAt?: string;
62
+ }
63
+
64
+ export interface RateLimitResponse {
65
+ success: boolean;
66
+ rateLimit: RateLimitRule;
67
+ parsed?: ParsedRateLimitText;
68
+ }
69
+
70
+ export function parseRateLimitText(text: string, options?: RateLimitTextOptions): Promise<ParsedRateLimitText>;
71
+ export function createRateLimitFromText(text: string, options?: RateLimitTextOptions): Promise<RateLimitResponse>;
72
+ export function createRateLimit(payload: Record<string, unknown>, options?: RateLimitAuthOptions): Promise<RateLimitResponse>;
73
+ export function listRateLimits(query?: Record<string, unknown>, options?: RateLimitAuthOptions): Promise<{
74
+ success: boolean;
75
+ rateLimits: RateLimitRule[];
76
+ total: number;
77
+ page: number;
78
+ limit: number;
79
+ }>;
80
+ export function testRateLimit(query?: Record<string, unknown>, options?: RateLimitAuthOptions): Promise<{
81
+ success: boolean;
82
+ limited: boolean;
83
+ matches: RateLimitRule[];
84
+ }>;
85
+ export function updateRateLimit(id: string, payload?: Record<string, unknown>, options?: RateLimitAuthOptions): Promise<RateLimitResponse>;
86
+ export function removeRateLimit(id: string, options?: RateLimitAuthOptions & { reason?: string }): Promise<RateLimitResponse>;
package/rate-limits.js ADDED
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const { api } = require('./cli/client');
4
+ const config = require('./cli/config');
5
+
6
+ function cleanText(value) {
7
+ return String(value || '').trim();
8
+ }
9
+
10
+ function authToken(options = {}) {
11
+ return (
12
+ options.token ||
13
+ options.apiKey ||
14
+ options.authToken ||
15
+ process.env.SECURENOW_TOKEN ||
16
+ process.env.SECURENOW_API_KEY ||
17
+ config.getToken() ||
18
+ config.getApiKey() ||
19
+ undefined
20
+ );
21
+ }
22
+
23
+ function requestOptions(options = {}) {
24
+ const token = authToken(options);
25
+ return token ? { token } : undefined;
26
+ }
27
+
28
+ function requireText(text) {
29
+ const value = cleanText(text);
30
+ if (!value) throw new Error('text is required');
31
+ return value;
32
+ }
33
+
34
+ async function parseRateLimitText(text, options = {}) {
35
+ return api.post(
36
+ '/rate-limits/parse',
37
+ {
38
+ text: requireText(text),
39
+ draft: options.draft && typeof options.draft === 'object' ? options.draft : {},
40
+ },
41
+ requestOptions(options)
42
+ );
43
+ }
44
+
45
+ async function createRateLimitFromText(text, options = {}) {
46
+ const body = {
47
+ text: requireText(text),
48
+ draft: options.draft && typeof options.draft === 'object' ? options.draft : {},
49
+ };
50
+ if (options.reason) body.reason = String(options.reason);
51
+ return api.post('/rate-limits/from-text', body, requestOptions(options));
52
+ }
53
+
54
+ async function createRateLimit(payload, options = {}) {
55
+ return api.post('/rate-limits', payload || {}, requestOptions(options));
56
+ }
57
+
58
+ async function listRateLimits(query = {}, options = {}) {
59
+ return api.get('/rate-limits', { query, ...(requestOptions(options) || {}) });
60
+ }
61
+
62
+ async function testRateLimit(query = {}, options = {}) {
63
+ return api.get('/rate-limits/check', { query, ...(requestOptions(options) || {}) });
64
+ }
65
+
66
+ async function updateRateLimit(id, payload = {}, options = {}) {
67
+ if (!id) throw new Error('id is required');
68
+ return api.put(`/rate-limits/${encodeURIComponent(id)}`, payload, requestOptions(options));
69
+ }
70
+
71
+ async function removeRateLimit(id, options = {}) {
72
+ if (!id) throw new Error('id is required');
73
+ const query = options.reason ? { reason: options.reason } : undefined;
74
+ return api.delete(`/rate-limits/${encodeURIComponent(id)}`, {
75
+ query,
76
+ ...(requestOptions(options) || {}),
77
+ });
78
+ }
79
+
80
+ module.exports = {
81
+ parseRateLimitText,
82
+ createRateLimitFromText,
83
+ createRateLimit,
84
+ listRateLimits,
85
+ testRateLimit,
86
+ updateRateLimit,
87
+ removeRateLimit,
88
+ };
package/resolve-ip.js CHANGED
@@ -110,8 +110,9 @@ function resolveClientIpWithDetails(request) {
110
110
  }
111
111
 
112
112
  if (LOOPBACK_RE.test(socketIp)) {
113
- const hostIp = [...getHostIps()][0] || '';
114
- if (hostIp) return { ...details, ip: hostIp, source: 'host-network' };
113
+ // A localhost request without proxy headers is app/server self-traffic.
114
+ // Rewriting it to the host's public IP can cause the app to block itself.
115
+ return { ...details, source: 'loopback' };
115
116
  }
116
117
 
117
118
  if (trustedProxy && getHostIps().has(socketIp)) {