securenow 7.6.9 → 7.7.1

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
@@ -415,8 +415,9 @@ Config files are stored in `~/.securenow/` (global) or `.securenow/` in the proj
415
415
  | `~/.securenow/config.json` | API URL, default app, output format |
416
416
  | `~/.securenow/credentials.json` | Auth token, app, API key, config - global (use `login --global`) |
417
417
  | `.securenow/credentials.json` | Auth token, app, API key, config, explanations - project-local default |
418
+ | `.securenow/credentials.<environment>.json` | Tokenless runtime credentials generated by `credentials runtime --env <environment>` |
418
419
 
419
- **Resolution order:** project `.securenow/credentials.json` -> global `~/.securenow/credentials.json`. Legacy CLI token overrides still work for existing automation.
420
+ **Resolution order:** project `.securenow/credentials.json` -> project `.securenow/credentials.<environment>.json` -> global `~/.securenow/credentials.json` -> global `~/.securenow/credentials.<environment>.json`. Legacy CLI token overrides still work for existing automation.
420
421
 
421
422
  ### Global Flags
422
423
 
@@ -464,6 +465,8 @@ npx securenow credentials runtime --env production
464
465
 
465
466
  # Store .securenow/credentials.production.json as a deployment secret file,
466
467
  # then materialize it as .securenow/credentials.json in the running app.
468
+ # Since v7.7.1, mounting it as .securenow/credentials.production.json
469
+ # also works when the canonical credentials.json file is absent.
467
470
 
468
471
  # Use --json for machine-readable output
469
472
  npx securenow logs --json --level error | jq '.logs'
@@ -511,6 +514,9 @@ npx securenow logs --json --level error | jq '.logs'
511
514
  | | `firewall test-ip <ip>` | Check if an IP would be blocked |
512
515
  | | `run --firewall-only <script>` | Preload firewall without OTel tracing overhead |
513
516
  | **Remediate** | `blocklist` | Blocked IPs |
517
+ | | `automation` | List blocklist automation rules |
518
+ | | `automation dry-run <id>` | Preview automation matches without writing blocks |
519
+ | | `automation execute <id> --yes` | Run an automation rule now |
514
520
  | | `blocklist add <ip>` | Block IP |
515
521
  | | `blocklist remove <id>` | Unblock IP |
516
522
  | | `blocklist stats` | Block stats |
@@ -1070,7 +1076,7 @@ npx securenow api-key set snk_live_abc123...
1070
1076
  npx securenow credentials runtime --env production
1071
1077
  ```
1072
1078
 
1073
- The SDK resolves the firewall key from project `./.securenow/credentials.json`, then global `~/.securenow/credentials.json`. Legacy `SECURENOW_API_KEY` overrides still work for existing deployments.
1079
+ The SDK resolves the firewall key from project `./.securenow/credentials.json`, then project `./.securenow/credentials.<environment>.json`, then global `~/.securenow/credentials.json`, then global `~/.securenow/credentials.<environment>.json`. Legacy `SECURENOW_API_KEY` overrides still work for existing deployments.
1074
1080
 
1075
1081
  On startup, you'll see:
1076
1082
 
@@ -1652,7 +1658,15 @@ After blocking an IP, it takes 10-15 seconds to propagate (one version-check int
1652
1658
 
1653
1659
  **Check 5: Are you behind a proxy?**
1654
1660
 
1655
- Add your proxy IPs to `config.networking.trustedProxies` in `.securenow/credentials.json` so the firewall sees the real client IP.
1661
+ Make sure your proxy forwards the real visitor IP:
1662
+
1663
+ ```nginx
1664
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1665
+ proxy_set_header X-Real-IP $remote_addr;
1666
+ proxy_set_header X-Forwarded-Proto $scheme;
1667
+ ```
1668
+
1669
+ SecureNow trusts private, loopback, and same-host proxy peers automatically. For external load balancers or public proxy IPs, add them to `config.networking.trustedProxies` in `.securenow/credentials.json`. If no forwarded header reaches the app, SecureNow can detect the attack shape but cannot recover the real visitor IP from traces.
1656
1670
 
1657
1671
  **Check 6: Using PM2?**
1658
1672
 
package/README.md CHANGED
@@ -152,11 +152,15 @@ npx securenow credentials runtime --env production
152
152
 
153
153
  It writes `.securenow/credentials.production.json`, with the same `app`, `apiKey`, `config`, and `_securenow.explanations` shape, but without the CLI OAuth `token`, `email`, or `expiresAt`. Store that JSON in your deployment secret manager and materialize it as `.securenow/credentials.json` at runtime.
154
154
 
155
+ Starting in v7.7.1, the SDK also accepts the generated filename directly. If `.securenow/credentials.json` is missing, it checks `.securenow/credentials.<environment>.json`, so a production app with `NODE_ENV=production` can mount `.securenow/credentials.production.json` directly.
156
+
155
157
  Resolution order:
156
158
 
157
159
  1. Project-local `.securenow/credentials.json`
158
- 2. Global `~/.securenow/credentials.json`
159
- 3. `package.json#name` (label only)
160
+ 2. Project-local `.securenow/credentials.<environment>.json`
161
+ 3. Global `~/.securenow/credentials.json`
162
+ 4. Global `~/.securenow/credentials.<environment>.json`
163
+ 5. `package.json#name` (label only)
160
164
 
161
165
  Legacy environment variables are fallback-only for existing installs. New local, CI, Docker, and production setups should use the credentials file.
162
166
 
@@ -395,11 +399,12 @@ After install, the `securenow` CLI is available via `npx securenow` or globally
395
399
  | File | Purpose |
396
400
  |---|---|
397
401
  | `./.securenow/credentials.json` | Project-local or production runtime credentials |
398
- | `./.securenow/credentials.production.json` | Tokenless production file generated by `securenow credentials runtime --env production` |
402
+ | `./.securenow/credentials.<environment>.json` | Tokenless runtime file generated by `securenow credentials runtime --env <environment>` |
399
403
  | `~/.securenow/credentials.json` | Global (with `login --global`) |
404
+ | `~/.securenow/credentials.<environment>.json` | Global environment-specific runtime credentials |
400
405
  | `~/.securenow/config.json` | API URL, default app, preferences |
401
406
 
402
- Resolution order: project `.securenow/` -> global `~/.securenow/` -> package name fallback. Legacy env vars are fallback-only for older installs.
407
+ Resolution order: project `.securenow/credentials.json` -> project `.securenow/credentials.<environment>.json` -> global `~/.securenow/credentials.json` -> global `~/.securenow/credentials.<environment>.json` -> package name fallback. Legacy env vars are fallback-only for older installs.
403
408
 
404
409
  Override the dashboard API with `securenow config set apiUrl <url>`.
405
410
 
package/SKILL-API.md CHANGED
@@ -273,7 +273,7 @@ Instruments document load, fetch, XMLHttpRequest, and user interactions with bro
273
273
 
274
274
  ## Firewall — Multi-Layer IP Blocking
275
275
 
276
- The firewall auto-activates once an API key is resolvable and the app firewall toggle is on. Since **v7.5.1**, `npx securenow login` enables the selected app firewall by default and writes the scoped key to `.securenow/credentials.json`; `securenow api-key set` can still write/rotate the key later. Production should use the tokenless file generated by `securenow credentials runtime --env production`. Resolution order: project `./.securenow/credentials.json` -> global `~/.securenow/credentials.json`; legacy env vars are fallback-only for existing deployments.
276
+ The firewall auto-activates once an API key is resolvable and the app firewall toggle is on. Since **v7.5.1**, `npx securenow login` enables the selected app firewall by default and writes the scoped key to `.securenow/credentials.json`; `securenow api-key set` can still write/rotate the key later. Production should use the tokenless file generated by `securenow credentials runtime --env production`. Resolution order: project `./.securenow/credentials.json` -> project `./.securenow/credentials.<environment>.json` -> global `~/.securenow/credentials.json` -> global `~/.securenow/credentials.<environment>.json`; legacy env vars are fallback-only for existing deployments.
277
277
 
278
278
  ```
279
279
  Layer 4: Cloud/Edge WAF → blocked at CDN (Cloudflare, AWS WAF, GCP Cloud Armor)
@@ -457,7 +457,7 @@ securenow redact @request.json --fields internal_id,sessionHash
457
457
 
458
458
  ## Credentials Configuration
459
459
 
460
- Local development and production use `.securenow/credentials.json`. Every setting below lives under `app` or `config`; `npx securenow credentials runtime --env production` creates a tokenless production file with the same structure. Environment variables are legacy fallbacks only.
460
+ Local development and production use `.securenow/credentials.json`. Every setting below lives under `app` or `config`; `npx securenow credentials runtime --env production` creates a tokenless production file with the same structure. Since v7.7.1, the SDK also accepts `.securenow/credentials.<environment>.json` when the canonical `credentials.json` file is absent, so `.securenow/credentials.production.json` can be mounted directly. Environment variables are legacy fallbacks only.
461
461
 
462
462
  ### App Identity
463
463
 
package/SKILL-CLI.md CHANGED
@@ -39,11 +39,11 @@ securenow whoami # verify session (shows email, app, auth source)
39
39
 
40
40
  **Default-on security (v7.5.1+):** after picking or creating the app, `securenow login` turns on that app's firewall toggle, mints an API key with `firewall:read + blocklist:read + allowlist:read` scopes, and writes it into `.securenow/credentials.json`. Traces, logs, POST body capture, multipart metadata capture, and the firewall are enabled by default. No `SECURENOW_API_KEY` env var is needed. To add or rotate a key later without re-running login, use `securenow api-key set snk_live_...` (see [API Key Management](#api-key-management) below).
41
41
 
42
- Credentials resolve in order: project `.securenow/credentials.json` -> global `~/.securenow/credentials.json`. Legacy env vars are fallback-only for existing deployments.
42
+ Credentials resolve in order: project `.securenow/credentials.json` -> project `.securenow/credentials.<environment>.json` -> global `~/.securenow/credentials.json` -> global `~/.securenow/credentials.<environment>.json`. Legacy env vars are fallback-only for existing deployments.
43
43
 
44
44
  The **firewall API key** should live in the same credentials file as `apiKey`. Legacy `SECURENOW_API_KEY` overrides are honored only when they start with `snk_live_`.
45
45
 
46
- For CI / Docker / production, use `securenow credentials runtime --env production` to generate a tokenless runtime file, then mount/copy it as `.securenow/credentials.json`.
46
+ For CI / Docker / production, use `securenow credentials runtime --env production` to generate a tokenless runtime file, then mount/copy it as `.securenow/credentials.json`. Since v7.7.1, mounting the generated `.securenow/credentials.production.json` filename directly also works when `credentials.json` is absent.
47
47
 
48
48
  **Environment model:** use one SecureNow app key for local, preview, staging, and production. The credentials field `config.runtime.deploymentEnvironment` separates traces/logs/firewall/forensics by environment. CLI security commands default to `production`; pass `--env local`, `--env staging`, or `--env all` only when that scope is intentional.
49
49
 
@@ -81,10 +81,11 @@ Config lives in `~/.securenow/` (global) and optionally `.securenow/` (per-proje
81
81
  | `~/.securenow/config.json` | `apiUrl`, `appUrl`, `defaultApp`, `output` |
82
82
  | `~/.securenow/credentials.json` | `token`, `email`, `expiresAt`, `apiKey`, `app`, `config` (global, use `login --global`) |
83
83
  | `.securenow/credentials.json` | `token`, `email`, `expiresAt`, `apiKey`, `app`, `config`, `_securenow.explanations` (project-local default) |
84
-
85
- **Credential resolution order:** `.securenow/credentials.json` (project) -> `~/.securenow/credentials.json` (global). Legacy env vars are fallback-only for existing deployments.
86
-
87
- **Firewall API key resolution (v7.5.1+):** project `.securenow/credentials.json` -> global `~/.securenow/credentials.json`. Use `securenow login` for default setup or `securenow api-key set` to rotate a key without touching env vars.
84
+ | `.securenow/credentials.<environment>.json` | Tokenless runtime credentials generated by `securenow credentials runtime --env <environment>` |
85
+
86
+ **Credential resolution order:** `.securenow/credentials.json` (project) -> `.securenow/credentials.<environment>.json` (project) -> `~/.securenow/credentials.json` (global) -> `~/.securenow/credentials.<environment>.json` (global). Legacy env vars are fallback-only for existing deployments.
87
+
88
+ **Firewall API key resolution (v7.5.1+):** project `.securenow/credentials.json` -> project `.securenow/credentials.<environment>.json` -> global `~/.securenow/credentials.json` -> global `~/.securenow/credentials.<environment>.json`. Use `securenow login` for default setup or `securenow api-key set` to rotate a key without touching env vars.
88
89
 
89
90
  ```bash
90
91
  securenow config set apiUrl https://api.securenow.ai
@@ -223,6 +224,7 @@ securenow human list --limit 20
223
224
  securenow human show 1 # inspect row 1 with AI report, DAG, proofs, trace links
224
225
  securenow human block 1 --yes --reason "AI evidence confirmed malicious"
225
226
  securenow human fp 1 --yes --reason "Scoped false positive after evidence review"
227
+ securenow human action 1 --status rejected --yes --reason "Tuning guard is too broad"
226
228
  securenow human prompt 1 # print a Codex/Claude MCP prompt for this row
227
229
  securenow human work --limit 10 # list queue + print MCP runbook for deep queue work
228
230
  ```
@@ -234,6 +236,7 @@ MCP parity:
234
236
  - `securenow_human_action_report`
235
237
  - `securenow_human_action_block`
236
238
  - `securenow_human_action_false_positive`
239
+ - `securenow_human_case_action_update`
237
240
  - prompts: `investigate_human_action_row`, `work_human_actions`
238
241
 
239
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,10 +246,12 @@ Write tools still require `confirm:true` plus a reason. False positives should s
243
246
  ```bash
244
247
  securenow alerts # list alert rules (default)
245
248
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
246
- securenow alerts rules show <id> # one rule; JSON: --json
247
- securenow alerts rules update <id> --applications-all # all current & future apps
248
- securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
249
- securenow alerts channels # list alert channels (Slack, email, etc.)
249
+ securenow alerts rules show <id> # one rule; JSON: --json
250
+ securenow alerts rules update <id> --applications-all # all current & future apps
251
+ securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
252
+ securenow alerts rules test <id> --mode dry_run --wait # validate a rule query
253
+ securenow alerts rules exclusions <id> list # embedded rule exclusions
254
+ securenow alerts channels # list alert channels (Slack, email, etc.)
250
255
  securenow alerts history [--limit N] # past triggered alerts
251
256
  ```
252
257
 
@@ -282,9 +287,9 @@ securenow api-map stats # endpoint statistics
282
287
  ### Firewall
283
288
 
284
289
  ```bash
285
- securenow firewall # show status (default)
286
- securenow firewall status --env production # layers, sync time, blocked count, API key info
287
- securenow firewall test-ip <ip> # check if IP would be blocked
290
+ securenow firewall # show status (default)
291
+ securenow firewall status --app <key> --env production # app/env toggle, sync time, blocked count
292
+ securenow firewall test-ip <ip> --app <key> --env local # check if IP would be blocked
288
293
  ```
289
294
 
290
295
  **Zero-config setup (v7.5.1+):** running `securenow login` enables the selected app's firewall toggle, auto-mints an API key (scoped `firewall:read + blocklist:read + allowlist:read`), and writes it to the credentials file after the app is selected. No `SECURENOW_API_KEY` env var needed. If the user already has a key, `securenow api-key set snk_live_...` achieves the same thing. See [the landing firewall page](https://securenow.ai/firewall) for an overview.
@@ -292,19 +297,28 @@ securenow firewall test-ip <ip> # check if IP would be blocked
292
297
  ### Blocklist — Block Malicious IPs
293
298
 
294
299
  ```bash
295
- securenow blocklist # list blocked IPs
296
- securenow blocklist list
297
- securenow blocklist add <ip> [--reason "Brute force"]
298
- securenow blocklist remove <id>
299
- securenow blocklist stats # block counts, top reasons
300
- ```
300
+ securenow blocklist # list blocked IPs
301
+ securenow blocklist list
302
+ securenow blocklist add <ip> --app <key> --env production --reason "Brute force"
303
+ securenow blocklist remove <id>
304
+ securenow blocklist stats # block counts, top reasons
305
+ ```
306
+
307
+ ### Automation Rules
308
+
309
+ ```bash
310
+ securenow automation # list blocklist automation rules
311
+ securenow automation show <id>
312
+ securenow automation dry-run <id> --limit 500
313
+ securenow automation execute <id> --yes
314
+ ```
301
315
 
302
316
  ### Allowlist — Restrict to Known IPs
303
317
 
304
318
  ```bash
305
- securenow allowlist # list allowed IPs
306
- securenow allowlist list
307
- securenow allowlist add <ip> [--label "Office"] [--reason "Corporate VPN"]
319
+ securenow allowlist # list allowed IPs
320
+ securenow allowlist list
321
+ securenow allowlist add <ip> --app <key> --env local --label "Office" --reason "Corporate VPN"
308
322
  securenow allowlist remove <id>
309
323
  securenow allowlist stats
310
324
  ```
package/app-config.js CHANGED
@@ -4,6 +4,9 @@
4
4
  * Shared SecureNow configuration resolver.
5
5
  *
6
6
  * Local development and production are driven by ./.securenow/credentials.json.
7
+ * Environment-specific runtime files such as
8
+ * ./.securenow/credentials.production.json are also accepted when the
9
+ * canonical file is not present.
7
10
  * Legacy environment variables are only fallback inputs for existing installs;
8
11
  * every SDK setting has a file-backed equivalent so customers do not need .env
9
12
  * files.
@@ -157,7 +160,9 @@ function clone(value) {
157
160
 
158
161
  function readJsonSafe(filepath) {
159
162
  try {
160
- return JSON.parse(fs.readFileSync(filepath, 'utf8'));
163
+ let content = fs.readFileSync(filepath, 'utf8');
164
+ if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
165
+ return JSON.parse(content);
161
166
  } catch {
162
167
  return null;
163
168
  }
@@ -191,6 +196,30 @@ function findUpFile(startDir, relativePath) {
191
196
  }
192
197
  }
193
198
 
199
+ function findUpFirstFile(startDir, relativePaths) {
200
+ if (!startDir) return null;
201
+ const candidates = Array.isArray(relativePaths) ? relativePaths.filter(Boolean) : [relativePaths].filter(Boolean);
202
+ if (!candidates.length) return null;
203
+
204
+ let dir;
205
+ try {
206
+ dir = path.resolve(startDir);
207
+ } catch {
208
+ return null;
209
+ }
210
+
211
+ while (true) {
212
+ for (const relativePath of candidates) {
213
+ const found = fileIfReadable(path.join(dir, relativePath));
214
+ if (found) return found;
215
+ }
216
+
217
+ const parent = path.dirname(dir);
218
+ if (!parent || parent === dir) return null;
219
+ dir = parent;
220
+ }
221
+ }
222
+
194
223
  function uniq(values) {
195
224
  const seen = new Set();
196
225
  const out = [];
@@ -202,6 +231,35 @@ function uniq(values) {
202
231
  return out;
203
232
  }
204
233
 
234
+ function credentialEnvironmentNames() {
235
+ const names = [];
236
+ const rawValues = [
237
+ rawEnv('SECURENOW_ENVIRONMENT'),
238
+ rawEnv('SECURENOW_DEPLOYMENT_ENVIRONMENT'),
239
+ rawEnv('NODE_ENV'),
240
+ ];
241
+
242
+ for (const rawValue of rawValues) {
243
+ const value = pick(rawValue);
244
+ if (value == null) continue;
245
+ const text = String(value).trim().toLowerCase();
246
+ if (/^[a-z0-9_.-]{1,64}$/.test(text)) names.push(text);
247
+ names.push(normalizeDeploymentEnvironment(text));
248
+ }
249
+
250
+ names.push('production');
251
+ return uniq(names);
252
+ }
253
+
254
+ function credentialRelativePaths() {
255
+ return uniq([
256
+ path.join('.securenow', 'credentials.json'),
257
+ ...credentialEnvironmentNames().map((envName) =>
258
+ path.join('.securenow', `credentials.${envName}.json`)
259
+ ),
260
+ ]);
261
+ }
262
+
205
263
  function resolveLocalCredentialsFile() {
206
264
  const starts = [];
207
265
  try {
@@ -212,8 +270,25 @@ function resolveLocalCredentialsFile() {
212
270
  if (process.argv && process.argv[1]) starts.push(path.dirname(process.argv[1]));
213
271
  if (require.main && require.main.filename) starts.push(path.dirname(require.main.filename));
214
272
 
273
+ const candidates = credentialRelativePaths();
215
274
  for (const start of uniq(starts)) {
216
- const found = findUpFile(start, path.join('.securenow', 'credentials.json'));
275
+ const found = findUpFirstFile(start, candidates);
276
+ if (found) return found;
277
+ }
278
+
279
+ return null;
280
+ }
281
+
282
+ function resolveGlobalCredentialsFile() {
283
+ let home;
284
+ try {
285
+ home = os.homedir();
286
+ } catch {
287
+ return null;
288
+ }
289
+
290
+ for (const relativePath of credentialRelativePaths()) {
291
+ const found = fileIfReadable(path.join(home, relativePath));
217
292
  if (found) return found;
218
293
  }
219
294
 
@@ -248,7 +323,7 @@ function loadLocalCredentials() {
248
323
 
249
324
  function loadGlobalCredentials() {
250
325
  try {
251
- return withCredentialDefaults(readJsonSafe(path.join(os.homedir(), '.securenow', 'credentials.json')));
326
+ return withCredentialDefaults(readJsonSafe(resolveGlobalCredentialsFile()));
252
327
  } catch {
253
328
  return null;
254
329
  }
@@ -668,6 +743,9 @@ module.exports = {
668
743
  listEnv,
669
744
  parseHeaders,
670
745
  headersToString,
746
+ credentialRelativePaths,
747
+ resolveLocalCredentialsFile,
748
+ resolveGlobalCredentialsFile,
671
749
  loadCredentials,
672
750
  loadLocalCredentials,
673
751
  loadGlobalCredentials,
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { api, requireAuth } = require('./client');
5
+ const ui = require('./ui');
6
+
7
+ function parseList(value) {
8
+ if (!value) return [];
9
+ return String(value).split(',').map((item) => item.trim()).filter(Boolean);
10
+ }
11
+
12
+ function parseJson(value, label) {
13
+ try {
14
+ return JSON.parse(value);
15
+ } catch (err) {
16
+ ui.error(`${label} must be valid JSON: ${err.message}`);
17
+ process.exit(1);
18
+ }
19
+ }
20
+
21
+ function readBody(flags) {
22
+ if (flags.body) return parseJson(flags.body, '--body');
23
+ if (flags.file) return parseJson(fs.readFileSync(flags.file, 'utf8'), '--file');
24
+ return null;
25
+ }
26
+
27
+ function bodyFromFlags(flags, requireName = false) {
28
+ const body = readBody(flags) || {};
29
+
30
+ if (flags.name) body.name = flags.name;
31
+ if (flags.description) body.description = flags.description;
32
+ if (flags.conditions) body.conditions = parseJson(flags.conditions, '--conditions');
33
+ if (flags.actions) body.actions = parseJson(flags.actions, '--actions');
34
+ if (flags.logic || flags['condition-logic']) body.conditionLogic = flags.logic || flags['condition-logic'];
35
+ if (flags.status) body.status = flags.status;
36
+
37
+ if (flags.app || flags.apps) {
38
+ body.applicationsAll = false;
39
+ body.applicationKeys = parseList(flags.app || flags.apps);
40
+ }
41
+ if (flags['applications-all']) {
42
+ body.applicationsAll = true;
43
+ body.applicationKeys = [];
44
+ }
45
+
46
+ if (flags.env || flags.environment || flags.environments) {
47
+ body.environmentsAll = false;
48
+ body.environments = parseList(flags.env || flags.environment || flags.environments);
49
+ }
50
+ if (flags['environments-all']) {
51
+ body.environmentsAll = true;
52
+ body.environments = [];
53
+ }
54
+
55
+ if (requireName && !body.name) {
56
+ ui.error('Rule name required. Pass --name or --body JSON.');
57
+ process.exit(1);
58
+ }
59
+
60
+ return body;
61
+ }
62
+
63
+ function formatApplications(rule) {
64
+ if (rule.applicationsAll !== false) return ui.c.cyan('all apps');
65
+ return (rule.applications || []).join(', ') || ui.c.dim('-');
66
+ }
67
+
68
+ function formatEnvironments(rule) {
69
+ if (rule.environmentsAll !== false) return ui.c.cyan('all envs');
70
+ return (rule.environments || []).join(', ') || ui.c.dim('-');
71
+ }
72
+
73
+ async function list(args, flags) {
74
+ requireAuth();
75
+ const s = ui.spinner('Fetching automation rules');
76
+ try {
77
+ const data = await api.get('/automation-rules');
78
+ const rules = data.rules || [];
79
+ s.stop(`Found ${rules.length} automation rule${rules.length === 1 ? '' : 's'}`);
80
+
81
+ if (flags.json) { ui.json(data); return; }
82
+
83
+ console.log('');
84
+ const rows = rules.map((rule) => [
85
+ ui.c.dim(ui.truncate(rule._id || rule.id, 12)),
86
+ rule.name || '-',
87
+ ui.statusBadge(rule.status || 'active'),
88
+ formatApplications(rule),
89
+ formatEnvironments(rule),
90
+ String(rule.stats?.totalMatches ?? 0),
91
+ rule.stats?.lastExecutedAt ? ui.timeAgo(rule.stats.lastExecutedAt) : '-',
92
+ ]);
93
+ ui.table(['ID', 'Name', 'Status', 'Apps', 'Envs', 'Matches', 'Last Run'], rows);
94
+ console.log('');
95
+ } catch (err) {
96
+ s.fail('Failed to fetch automation rules');
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ async function show(args, flags) {
102
+ requireAuth();
103
+ const id = args[0];
104
+ if (!id) {
105
+ ui.error('Usage: securenow automation show <rule-id>');
106
+ process.exit(1);
107
+ }
108
+
109
+ const s = ui.spinner('Fetching automation rule');
110
+ try {
111
+ const data = await api.get(`/automation-rules/${encodeURIComponent(id)}`);
112
+ const rule = data.rule || data;
113
+ s.stop('Automation rule loaded');
114
+
115
+ if (flags.json) { ui.json(data); return; }
116
+
117
+ console.log('');
118
+ ui.heading(rule.name || 'Automation rule');
119
+ ui.keyValue([
120
+ ['ID', rule._id || rule.id || id],
121
+ ['Status', rule.status || '-'],
122
+ ['Applications', formatApplications(rule)],
123
+ ['Environments', formatEnvironments(rule)],
124
+ ['Condition logic', rule.conditionLogic || 'AND'],
125
+ ['Conditions', JSON.stringify(rule.conditions || [])],
126
+ ['Actions', JSON.stringify(rule.actions || [])],
127
+ ['Total executions', String(rule.stats?.totalExecutions ?? 0)],
128
+ ['Total matches', String(rule.stats?.totalMatches ?? 0)],
129
+ ]);
130
+ console.log('');
131
+ } catch (err) {
132
+ s.fail('Failed to fetch automation rule');
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ async function create(args, flags) {
138
+ requireAuth();
139
+ const body = bodyFromFlags(flags, true);
140
+ const s = ui.spinner('Creating automation rule');
141
+ try {
142
+ const data = await api.post('/automation-rules', body);
143
+ s.stop('Automation rule created');
144
+ if (flags.json) { ui.json(data); return; }
145
+ ui.success(`${data.rule?.name || body.name} created`);
146
+ } catch (err) {
147
+ s.fail('Failed to create automation rule');
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ async function update(args, flags) {
153
+ requireAuth();
154
+ const id = args[0];
155
+ if (!id) {
156
+ ui.error('Usage: securenow automation update <rule-id> [--body JSON]');
157
+ process.exit(1);
158
+ }
159
+
160
+ const body = bodyFromFlags(flags, false);
161
+ if (Object.keys(body).length === 0) {
162
+ ui.error('Nothing to update. Pass --body, --file, or field flags.');
163
+ process.exit(1);
164
+ }
165
+
166
+ const s = ui.spinner('Updating automation rule');
167
+ try {
168
+ const data = await api.put(`/automation-rules/${encodeURIComponent(id)}`, body);
169
+ s.stop('Automation rule updated');
170
+ if (flags.json) { ui.json(data); return; }
171
+ ui.success('Automation rule updated');
172
+ } catch (err) {
173
+ s.fail('Failed to update automation rule');
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ async function dryRun(args, flags) {
179
+ requireAuth();
180
+ const id = args[0];
181
+ if (!id) {
182
+ ui.error('Usage: securenow automation dry-run <rule-id> [--limit 500] [--sample-limit 20]');
183
+ process.exit(1);
184
+ }
185
+
186
+ const body = {};
187
+ if (flags.limit) body.limit = Number(flags.limit);
188
+ if (flags['sample-limit']) body.sampleLimit = Number(flags['sample-limit']);
189
+
190
+ const s = ui.spinner('Dry-running automation rule');
191
+ try {
192
+ const data = await api.post(`/automation-rules/${encodeURIComponent(id)}/dry-run`, body);
193
+ s.stop('Dry-run complete');
194
+ if (flags.json) { ui.json(data); return; }
195
+
196
+ const results = data.results || {};
197
+ console.log('');
198
+ ui.keyValue([
199
+ ['Scanned IPs', String(results.scanned ?? 0)],
200
+ ['Matched IPs', String(results.matched ?? 0)],
201
+ ['Sample count', String((results.samples || []).length)],
202
+ ]);
203
+ if ((results.samples || []).length) {
204
+ console.log('');
205
+ const rows = results.samples.map((sample) => [
206
+ sample.ip,
207
+ ui.truncate((sample.path || []).join(', '), 32),
208
+ ui.truncate((sample.attackType || []).join(', '), 28),
209
+ (sample.environment || []).join(', ') || '-',
210
+ String(sample.riskScore ?? '-'),
211
+ ]);
212
+ ui.table(['IP', 'Paths', 'Attack', 'Env', 'Risk'], rows);
213
+ }
214
+ console.log('');
215
+ } catch (err) {
216
+ s.fail('Failed to dry-run automation rule');
217
+ throw err;
218
+ }
219
+ }
220
+
221
+ async function execute(args, flags) {
222
+ requireAuth();
223
+ const id = args[0];
224
+ if (!id) {
225
+ ui.error('Usage: securenow automation execute <rule-id> --yes');
226
+ process.exit(1);
227
+ }
228
+ if (!flags.yes && !flags.force) {
229
+ const ok = await ui.confirm('Execute this automation rule now? It may add IPs to the blocklist.');
230
+ if (!ok) { ui.info('Cancelled'); return; }
231
+ }
232
+
233
+ const s = ui.spinner('Executing automation rule');
234
+ try {
235
+ const data = await api.post(`/automation-rules/${encodeURIComponent(id)}/execute`, {});
236
+ s.stop('Automation rule executed');
237
+ if (flags.json) { ui.json(data); return; }
238
+ const r = data.results || {};
239
+ ui.keyValue([
240
+ ['Scanned', String(r.scanned ?? 0)],
241
+ ['Matched', String(r.matched ?? 0)],
242
+ ['Blocked', String(r.blocked ?? 0)],
243
+ ['Skipped', String(r.skipped ?? 0)],
244
+ ['Errors', String(r.errors ?? 0)],
245
+ ]);
246
+ } catch (err) {
247
+ s.fail('Failed to execute automation rule');
248
+ throw err;
249
+ }
250
+ }
251
+
252
+ async function remove(args, flags) {
253
+ requireAuth();
254
+ const id = args[0];
255
+ if (!id) {
256
+ ui.error('Usage: securenow automation delete <rule-id> --yes');
257
+ process.exit(1);
258
+ }
259
+ if (!flags.yes && !flags.force) {
260
+ const ok = await ui.confirm('Delete this automation rule?');
261
+ if (!ok) { ui.info('Cancelled'); return; }
262
+ }
263
+
264
+ const s = ui.spinner('Deleting automation rule');
265
+ try {
266
+ const data = await api.delete(`/automation-rules/${encodeURIComponent(id)}`);
267
+ s.stop('Automation rule deleted');
268
+ if (flags.json) ui.json(data);
269
+ } catch (err) {
270
+ s.fail('Failed to delete automation rule');
271
+ throw err;
272
+ }
273
+ }
274
+
275
+ module.exports = { list, show, create, update, dryRun, execute, remove };
package/cli/config.js CHANGED
@@ -28,7 +28,9 @@ function ensureDir(dir) {
28
28
 
29
29
  function loadJSON(filepath) {
30
30
  try {
31
- return JSON.parse(fs.readFileSync(filepath, 'utf8'));
31
+ let content = fs.readFileSync(filepath, 'utf8');
32
+ if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
33
+ return JSON.parse(content);
32
34
  } catch {
33
35
  return {};
34
36
  }
@@ -50,17 +52,16 @@ function credentialsFileForLocal(local) {
50
52
  }
51
53
 
52
54
  function hasLocalCredentials() {
53
- return fs.existsSync(LOCAL_CREDENTIALS_FILE);
55
+ return !!appConfig.resolveLocalCredentialsFile();
54
56
  }
55
57
 
56
58
  function resolveCredentialsFile() {
57
- if (fs.existsSync(LOCAL_CREDENTIALS_FILE)) return LOCAL_CREDENTIALS_FILE;
58
- return CREDENTIALS_FILE;
59
+ return appConfig.resolveLocalCredentialsFile() || appConfig.resolveGlobalCredentialsFile() || CREDENTIALS_FILE;
59
60
  }
60
61
 
61
62
  function getAuthSource() {
62
63
  if (process.env.SECURENOW_TOKEN) return 'env (SECURENOW_TOKEN)';
63
- if (fs.existsSync(LOCAL_CREDENTIALS_FILE)) return 'project (.securenow/)';
64
+ if (appConfig.resolveLocalCredentialsFile()) return 'project (.securenow/)';
64
65
  return 'global (~/.securenow/)';
65
66
  }
66
67
 
@@ -91,9 +92,8 @@ function setConfigValue(key, value) {
91
92
  }
92
93
 
93
94
  function loadCredentials() {
94
- const credentials = fs.existsSync(LOCAL_CREDENTIALS_FILE)
95
- ? loadJSON(LOCAL_CREDENTIALS_FILE)
96
- : loadJSON(CREDENTIALS_FILE);
95
+ const credentialsFile = resolveCredentialsFile();
96
+ const credentials = loadJSON(credentialsFile);
97
97
  return appConfig.withCredentialDefaults(credentials) || {};
98
98
  }
99
99