securenow 7.7.16 → 7.8.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 +35 -22
- package/README.md +50 -32
- package/SKILL-API.md +48 -24
- package/SKILL-CLI.md +61 -40
- package/app-config.js +79 -16
- package/cli/apiKey.js +7 -7
- package/cli/apps.js +3 -3
- package/cli/auth.js +113 -31
- package/cli/client.js +14 -13
- package/cli/config.js +219 -45
- package/cli/credentials.js +3 -3
- package/cli/diagnostics.js +5 -6
- package/cli/firewall.js +19 -7
- package/cli/init.js +5 -5
- package/cli/security.js +31 -11
- package/cli.js +57 -22
- package/firewall-only.js +3 -4
- package/firewall.js +110 -35
- package/mcp/catalog.js +43 -30
- package/mcp/server.js +73 -12
- package/nextjs.js +4 -1
- package/nuxt-server-plugin.mjs +7 -4
- package/otel-defaults.js +11 -0
- package/package.json +2 -1
- package/tracing.js +4 -1
package/cli/diagnostics.js
CHANGED
|
@@ -22,8 +22,8 @@ function resolvedConfig(options = {}) {
|
|
|
22
22
|
? (noUuid ? baseName : `${baseName}-<uuid-per-worker>`)
|
|
23
23
|
: '(auto-generated)';
|
|
24
24
|
const apiKey = firewall.apiKey || '';
|
|
25
|
-
const firewallEnabled = !!apiKey
|
|
26
|
-
const firewallLocalEnabled =
|
|
25
|
+
const firewallEnabled = !!apiKey;
|
|
26
|
+
const firewallLocalEnabled = true;
|
|
27
27
|
|
|
28
28
|
return {
|
|
29
29
|
serviceName,
|
|
@@ -359,7 +359,6 @@ async function probe({ endpoint, method = 'POST', headers = {}, body = null, tim
|
|
|
359
359
|
function firewallStatusLabel(cfg) {
|
|
360
360
|
if (cfg.firewallEnabled) return ui.c.green('enabled');
|
|
361
361
|
if (!cfg.apiKey) return ui.c.dim('disabled (missing firewall API key)');
|
|
362
|
-
if (!cfg.firewallLocalEnabled) return ui.c.yellow('disabled (config.firewall.enabled=false)');
|
|
363
362
|
return ui.c.dim('disabled');
|
|
364
363
|
}
|
|
365
364
|
|
|
@@ -491,7 +490,7 @@ async function doctor(_args, flags) {
|
|
|
491
490
|
warnings.push('OpenTelemetry diagnostic log level is `none`; provider registration/export errors are hidden. Set config.otel.logLevel to `error`/`warn`, or temporarily run with OTEL_LOG_LEVEL=debug.');
|
|
492
491
|
}
|
|
493
492
|
if (!cfg.appKey) {
|
|
494
|
-
warnings.push('No app key resolved. Run `npx securenow
|
|
493
|
+
warnings.push('No app key resolved. Run `npx securenow app connect` or set app.key in .securenow/runtime.json.');
|
|
495
494
|
}
|
|
496
495
|
if (cfg.instance === 'https://ingest.securenow.ai') {
|
|
497
496
|
warnings.push('Using the SecureNow ingest gateway. Dedicated instances are routed server-side after policy checks.');
|
|
@@ -501,9 +500,9 @@ async function doctor(_args, flags) {
|
|
|
501
500
|
warnings.push('Using the legacy free-trial collector directly. Regenerate credentials so telemetry flows through https://ingest.securenow.ai.');
|
|
502
501
|
}
|
|
503
502
|
if (!cfg.apiKey && token) {
|
|
504
|
-
warnings.push('CLI/MCP is
|
|
503
|
+
warnings.push('Admin CLI/MCP auth is connected, but runtime firewall enforcement key is missing. Run `npx securenow app connect` or `npx securenow api-key set snk_live_...` to refresh .securenow/runtime.json.');
|
|
505
504
|
} else if (!cfg.apiKey) {
|
|
506
|
-
warnings.push('Runtime firewall enforcement key is missing. Run `npx securenow
|
|
505
|
+
warnings.push('Runtime firewall enforcement key is missing. Run `npx securenow app connect` or `npx securenow api-key set snk_live_...` to refresh .securenow/runtime.json.');
|
|
507
506
|
}
|
|
508
507
|
|
|
509
508
|
const ok = checks.every((c) => c.ok);
|
package/cli/firewall.js
CHANGED
|
@@ -32,7 +32,9 @@ async function status(args, flags) {
|
|
|
32
32
|
console.log(` ${enabledLabel}`);
|
|
33
33
|
console.log('');
|
|
34
34
|
ui.keyValue([
|
|
35
|
-
['Blocked IPs', `${data.totalIps} total
|
|
35
|
+
['Blocked IPs', `${data.totalIps} total`],
|
|
36
|
+
['Global blocked IPs', `${data.globalBlockIps ?? data.totalIps} total (${data.exactCount} exact + ${data.cidrCount} CIDR ranges)`],
|
|
37
|
+
['Scoped block rules', String(data.scopedBlockRuleCount ?? 0)],
|
|
36
38
|
['App scope', appKey || 'all apps'],
|
|
37
39
|
['Environment', data.environment || environment],
|
|
38
40
|
['Last updated', data.updatedAt || 'unknown'],
|
|
@@ -77,6 +79,8 @@ async function testIp(args, flags) {
|
|
|
77
79
|
const appKey = await resolveAppKey(flags);
|
|
78
80
|
const query = { environment };
|
|
79
81
|
if (appKey) query.appKey = appKey;
|
|
82
|
+
if (flags.path || flags.route) query.path = flags.path || flags.route;
|
|
83
|
+
if (flags.method) query.method = flags.method;
|
|
80
84
|
const data = await api.get(`/firewall/check/${encodeURIComponent(ip)}`, { query });
|
|
81
85
|
|
|
82
86
|
s.stop(`IP ${ip} checked`);
|
|
@@ -99,10 +103,16 @@ async function testIp(args, flags) {
|
|
|
99
103
|
console.log(` ${ui.c.dim('Allowlist is active — only listed IPs are permitted')}`);
|
|
100
104
|
} else {
|
|
101
105
|
console.log(` ${ui.c.bold(ui.c.red('BLOCKED'))} — ${ip} is in the blocklist`);
|
|
102
|
-
if (data.matchedEntry && data.matchedEntry !== ip) {
|
|
103
|
-
console.log(` ${ui.c.dim(`Matched by: ${data.matchedEntry}`)}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
+
if (data.matchedEntry && data.matchedEntry !== ip) {
|
|
107
|
+
console.log(` ${ui.c.dim(`Matched by: ${data.matchedEntry}`)}`);
|
|
108
|
+
}
|
|
109
|
+
if (data.matchedRule) {
|
|
110
|
+
const scope = data.matchedRule.pathPattern
|
|
111
|
+
? `${data.matchedRule.method || 'ALL'} ${data.matchedRule.pathMatchMode || 'prefix'}:${data.matchedRule.pathPattern}`
|
|
112
|
+
: data.matchedRule.method || 'ALL';
|
|
113
|
+
console.log(` ${ui.c.dim(`Rule scope: ${scope}`)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
106
116
|
} else {
|
|
107
117
|
if (data.allowlisted) {
|
|
108
118
|
console.log(` ${ui.c.bold(ui.c.green('ALLOWED'))} — ${ip} is on the allowlist`);
|
|
@@ -111,7 +121,9 @@ async function testIp(args, flags) {
|
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
console.log(` ${ui.c.dim(`App scope: ${appKey || 'all apps'} · Environment: ${data.environment || environment}`)}`);
|
|
114
|
-
console.log(` ${ui.c.dim(`
|
|
124
|
+
if (data.path) console.log(` ${ui.c.dim(`Request scope: ${data.method || 'GET'} ${data.path}`)}`);
|
|
125
|
+
if (data.scopedMatchRequiresPath) console.log(` ${ui.c.dim('Scoped block rules exist; pass --path to test route-specific matches')}`);
|
|
126
|
+
console.log(` ${ui.c.dim(`Blocklist contains ${data.totalBlockedIps} entries (${data.scopedBlockRuleCount || 0} scoped)`)}`);
|
|
115
127
|
if (data.allowlistActive) {
|
|
116
128
|
console.log(` ${ui.c.dim('Allowlist is active')}`);
|
|
117
129
|
}
|
|
@@ -136,7 +148,7 @@ async function setEnabled(args, flags, enabled) {
|
|
|
136
148
|
requireAuth();
|
|
137
149
|
const appKey = await resolveAppKey(flags);
|
|
138
150
|
if (!appKey) {
|
|
139
|
-
ui.error('No app selected. Pass --app <key> or run `securenow
|
|
151
|
+
ui.error('No app selected. Pass --app <key> or run `securenow app connect` / `securenow apps default <key>`.');
|
|
140
152
|
process.exit(1);
|
|
141
153
|
}
|
|
142
154
|
|
package/cli/init.js
CHANGED
|
@@ -81,7 +81,7 @@ async function init(_args, flags) {
|
|
|
81
81
|
|
|
82
82
|
console.log('');
|
|
83
83
|
ui.success('Setup complete.');
|
|
84
|
-
ui.info('Run `npx securenow
|
|
84
|
+
ui.info('Run `npx securenow app connect` if this project is not linked to an app yet.');
|
|
85
85
|
ui.info('Then verify with `npx securenow test-span` and `npx securenow status`.');
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -101,10 +101,10 @@ function initCredentials(flags) {
|
|
|
101
101
|
process.exit(1);
|
|
102
102
|
}
|
|
103
103
|
config.setApiKey(explicitApiKey, { local: true });
|
|
104
|
-
ui.success('Stored firewall API key in .securenow/
|
|
104
|
+
ui.success('Stored firewall API key in .securenow/runtime.json');
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
ui.success('Ensured .securenow/
|
|
107
|
+
ui.success('Ensured .securenow/runtime.json has secure defaults and explanations');
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
async function initNextJs(dir, project, flags) {
|
|
@@ -233,8 +233,8 @@ function printAgentPrompt(kind, filename, major, project) {
|
|
|
233
233
|
ui.heading('Codex/Claude prompt');
|
|
234
234
|
console.log([
|
|
235
235
|
'Set up SecureNow in this existing Next.js project without using .env files.',
|
|
236
|
-
'Use .securenow/
|
|
237
|
-
'Ignore only .securenow/
|
|
236
|
+
'Use .securenow/runtime.json for local SDK runtime configuration and .securenow/credentials.<env>.json for production runtime exports; do not add .env files.',
|
|
237
|
+
'Ignore only SecureNow secret files (.securenow/admin.json, .securenow/runtime.json, and .securenow/credentials*.json); keep the .securenow/ directory itself trackable for repo-owned files.',
|
|
238
238
|
commands
|
|
239
239
|
? `Use the project scripts for verification when appropriate: ${commands}. Ask before starting long-running dev/start servers, and ask which command to use if these scripts are not the right customer workflow.`
|
|
240
240
|
: 'Ask the customer which command starts, builds, and tests this app because package.json does not expose an obvious script.',
|
package/cli/security.js
CHANGED
|
@@ -441,7 +441,14 @@ async function alertHistoryList(args, flags) {
|
|
|
441
441
|
|
|
442
442
|
// ── Blocklist ──
|
|
443
443
|
|
|
444
|
-
|
|
444
|
+
function describeBlockTarget(entry) {
|
|
445
|
+
const parts = [entry.ip || entry.cidr || '-'];
|
|
446
|
+
if (entry.method && entry.method !== 'ALL') parts.push(entry.method);
|
|
447
|
+
if (entry.pathPattern) parts.push(`${entry.pathMatchMode || 'prefix'}:${entry.pathPattern}`);
|
|
448
|
+
return parts.join(' ');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function blocklistList(args, flags) {
|
|
445
452
|
requireAuth();
|
|
446
453
|
const s = ui.spinner('Fetching blocklist');
|
|
447
454
|
try {
|
|
@@ -465,7 +472,7 @@ async function blocklistList(args, flags) {
|
|
|
465
472
|
console.log('');
|
|
466
473
|
const rows = items.map(b => [
|
|
467
474
|
ui.c.dim(ui.truncate(b._id, 12)),
|
|
468
|
-
ui.c.red(b
|
|
475
|
+
ui.c.red(describeBlockTarget(b)),
|
|
469
476
|
ui.truncate(b.reason || '', 40),
|
|
470
477
|
b.source || '—',
|
|
471
478
|
b.status || 'active',
|
|
@@ -475,7 +482,7 @@ async function blocklistList(args, flags) {
|
|
|
475
482
|
b.unblockedAt ? ui.timeAgo(b.unblockedAt) : ui.c.dim('—'),
|
|
476
483
|
b.expiresAt ? new Date(b.expiresAt).toLocaleDateString() : ui.c.dim('permanent'),
|
|
477
484
|
]);
|
|
478
|
-
ui.table(['ID', '
|
|
485
|
+
ui.table(['ID', 'Target', 'Reason', 'Source', 'Status', 'App', 'Env', 'Added', 'Unblocked', 'Expires'], rows);
|
|
479
486
|
console.log('');
|
|
480
487
|
} catch (err) {
|
|
481
488
|
s.fail('Failed to fetch blocklist');
|
|
@@ -496,11 +503,16 @@ async function blocklistAdd(args, flags) {
|
|
|
496
503
|
if (flags.duration) body.duration = flags.duration;
|
|
497
504
|
if (flags.app) body.appKey = flags.app;
|
|
498
505
|
if (flags.env || flags.environment) body.environment = flags.env || flags.environment;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
506
|
+
if (flags.route || flags.path || flags.pattern) body.pathPattern = flags.route || flags.path || flags.pattern;
|
|
507
|
+
if (flags.mode || flags['path-mode']) body.pathMatchMode = flags.mode || flags['path-mode'];
|
|
508
|
+
if (flags.method) body.method = flags.method;
|
|
509
|
+
|
|
510
|
+
const target = describeBlockTarget({ ip, method: body.method, pathPattern: body.pathPattern, pathMatchMode: body.pathMatchMode });
|
|
511
|
+
const s = ui.spinner(`Blocking ${target}`);
|
|
512
|
+
try {
|
|
513
|
+
const data = await api.post('/blocklist', body);
|
|
514
|
+
s.stop(`${target} added to blocklist`);
|
|
515
|
+
if (flags.json) ui.json(data);
|
|
504
516
|
} catch (err) {
|
|
505
517
|
s.fail('Failed to add to blocklist');
|
|
506
518
|
throw err;
|
|
@@ -611,9 +623,17 @@ async function allowlistAdd(args, flags) {
|
|
|
611
623
|
body.applicationKeys = String(flags.app).split(',').map((x) => x.trim()).filter(Boolean);
|
|
612
624
|
}
|
|
613
625
|
if (flags.env || flags.environment) body.environment = flags.env || flags.environment;
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
626
|
+
|
|
627
|
+
if (!flags.force && !flags.yes) {
|
|
628
|
+
ui.warn('IP Allowlist is deny-by-default: once active, only listed IPs can reach the scoped app/environment and all others are blocked.');
|
|
629
|
+
ui.info('Use `securenow trusted add` instead if this IP should be trusted without locking out normal visitors.');
|
|
630
|
+
const ok = await ui.confirm('Add this IP to the restrictive allowlist?');
|
|
631
|
+
if (!ok) { ui.info('Cancelled'); return; }
|
|
632
|
+
}
|
|
633
|
+
body.allowlistDenyAllApproved = true;
|
|
634
|
+
|
|
635
|
+
const s = ui.spinner(`Allowing ${ip}`);
|
|
636
|
+
try {
|
|
617
637
|
await api.post('/allowlist', body);
|
|
618
638
|
s.stop(`${ip} added to allowlist`);
|
|
619
639
|
} catch (err) {
|
package/cli.js
CHANGED
|
@@ -40,12 +40,12 @@ function parseArgs(argv) {
|
|
|
40
40
|
|
|
41
41
|
const COMMANDS = {
|
|
42
42
|
init: {
|
|
43
|
-
desc: 'Set up SecureNow in the current project (instrumentation +
|
|
43
|
+
desc: 'Set up SecureNow in the current project (instrumentation + runtime credentials)',
|
|
44
44
|
usage: 'securenow init [--env local] [--key <API_KEY>] [--ts|--js] [--src|--root] [--force]',
|
|
45
45
|
flags: {
|
|
46
|
-
env: 'Deployment environment to write into .securenow/
|
|
46
|
+
env: 'Deployment environment to write into .securenow/runtime.json (default: local)',
|
|
47
47
|
environment: 'Alias for --env',
|
|
48
|
-
key: 'Firewall API key to write to .securenow/
|
|
48
|
+
key: 'Firewall API key to write to .securenow/runtime.json',
|
|
49
49
|
'api-key': 'Alias for --key',
|
|
50
50
|
typescript: 'Force TypeScript',
|
|
51
51
|
javascript: 'Force JavaScript',
|
|
@@ -55,28 +55,63 @@ const COMMANDS = {
|
|
|
55
55
|
},
|
|
56
56
|
run: (a, f) => require('./cli/init').init(a, f),
|
|
57
57
|
},
|
|
58
|
-
login: {
|
|
59
|
-
desc: '
|
|
60
|
-
usage: 'securenow login [--token <TOKEN>] [--global]',
|
|
58
|
+
login: {
|
|
59
|
+
desc: 'Onboard SecureNow admin auth and app runtime config',
|
|
60
|
+
usage: 'securenow login [--token <TOKEN>] [--global]',
|
|
61
61
|
flags: {
|
|
62
62
|
token: 'Authenticate with a token directly',
|
|
63
63
|
global: 'Save credentials to ~/.securenow/ (shared across all projects)',
|
|
64
|
-
},
|
|
65
|
-
run: (a, f) => require('./cli/auth').login(a, f),
|
|
66
|
-
},
|
|
67
|
-
|
|
68
|
-
desc: '
|
|
69
|
-
usage: 'securenow
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
64
|
+
},
|
|
65
|
+
run: (a, f) => require('./cli/auth').login(a, f),
|
|
66
|
+
},
|
|
67
|
+
admin: {
|
|
68
|
+
desc: 'Manage admin/control-plane CLI and MCP auth',
|
|
69
|
+
usage: 'securenow admin <subcommand> [options]',
|
|
70
|
+
sub: {
|
|
71
|
+
login: {
|
|
72
|
+
desc: 'Authenticate admin/control-plane CLI and MCP tools',
|
|
73
|
+
usage: 'securenow admin login [--token <TOKEN>] [--global]',
|
|
74
|
+
flags: {
|
|
75
|
+
token: 'Authenticate with a token directly',
|
|
76
|
+
global: 'Save admin auth to ~/.securenow/admin.json',
|
|
77
|
+
},
|
|
78
|
+
run: (a, f) => require('./cli/auth').adminLogin(a, f),
|
|
79
|
+
},
|
|
80
|
+
logout: {
|
|
81
|
+
desc: 'Clear admin/control-plane auth',
|
|
82
|
+
usage: 'securenow admin logout [--global]',
|
|
83
|
+
flags: { global: 'Clear global admin auth (~/.securenow/admin.json)' },
|
|
84
|
+
run: (a, f) => require('./cli/auth').logout(a, f),
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
defaultSub: 'login',
|
|
88
|
+
},
|
|
89
|
+
app: {
|
|
90
|
+
desc: 'Connect the current project runtime to a SecureNow app',
|
|
91
|
+
usage: 'securenow app <subcommand> [options]',
|
|
92
|
+
sub: {
|
|
93
|
+
connect: {
|
|
94
|
+
desc: 'Select/create app, mint firewall key, and write SDK runtime config',
|
|
95
|
+
usage: 'securenow app connect [--global]',
|
|
96
|
+
flags: { global: 'Save runtime config to ~/.securenow/runtime.json' },
|
|
97
|
+
run: (a, f) => require('./cli/auth').appConnect(a, f),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
defaultSub: 'connect',
|
|
101
|
+
},
|
|
102
|
+
logout: {
|
|
103
|
+
desc: 'Clear admin/control-plane auth',
|
|
104
|
+
usage: 'securenow logout [--global]',
|
|
105
|
+
flags: { global: 'Clear global admin auth (~/.securenow/admin.json)' },
|
|
106
|
+
run: (a, f) => require('./cli/auth').logout(a, f),
|
|
107
|
+
},
|
|
108
|
+
whoami: {
|
|
109
|
+
desc: 'Show admin auth and SDK runtime status',
|
|
75
110
|
usage: 'securenow whoami',
|
|
76
111
|
run: () => require('./cli/auth').whoami(),
|
|
77
112
|
},
|
|
78
113
|
'api-key': {
|
|
79
|
-
desc: 'Manage the firewall API key stored in
|
|
114
|
+
desc: 'Manage the firewall API key stored in runtime credentials',
|
|
80
115
|
usage: 'securenow api-key <subcommand> [options]',
|
|
81
116
|
sub: {
|
|
82
117
|
create: {
|
|
@@ -90,7 +125,7 @@ const COMMANDS = {
|
|
|
90
125
|
run: (a, f) => require('./cli/apiKey').create(a, f),
|
|
91
126
|
},
|
|
92
127
|
set: {
|
|
93
|
-
desc: 'Save an API key (snk_live_...) to
|
|
128
|
+
desc: 'Save an API key (snk_live_...) to runtime credentials',
|
|
94
129
|
usage: 'securenow api-key set <snk_live_...> [--global]',
|
|
95
130
|
flags: { global: 'Save to ~/.securenow/ instead of project-local' },
|
|
96
131
|
run: (a, f) => require('./cli/apiKey').set(a, f),
|
|
@@ -273,7 +308,7 @@ const COMMANDS = {
|
|
|
273
308
|
apps: { desc: 'List apps with their firewall on/off state', run: (a, f) => require('./cli/firewall').appsList(a, f) },
|
|
274
309
|
enable: { desc: 'Turn the firewall ON for an app environment', usage: 'securenow firewall enable [--app <key>] [--env production]', flags: { app: 'App key (defaults to logged-in app)', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').enable(a, f) },
|
|
275
310
|
disable: { desc: 'Turn the firewall OFF for an app environment', usage: 'securenow firewall disable [--app <key>] [--env local]', flags: { app: 'App key (defaults to logged-in app)', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').disable(a, f) },
|
|
276
|
-
'test-ip': { desc: 'Check if an IP would be blocked', usage: 'securenow firewall test-ip <ip> [--env production]', flags: { env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').testIp(a, f) },
|
|
311
|
+
'test-ip': { desc: 'Check if an IP would be blocked', usage: 'securenow firewall test-ip <ip> [--path /admin/users] [--method GET] [--env production]', flags: { env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').testIp(a, f) },
|
|
277
312
|
},
|
|
278
313
|
defaultSub: 'status',
|
|
279
314
|
},
|
|
@@ -379,10 +414,10 @@ const COMMANDS = {
|
|
|
379
414
|
blocklist: {
|
|
380
415
|
desc: 'Manage IP blocklist',
|
|
381
416
|
usage: 'securenow blocklist <subcommand> [options]',
|
|
382
|
-
flags: { app: 'Scope to app key', env: 'Scope to environment', environment: 'Alias for --env', page: 'Page number', limit: 'Max results', status: 'Entry status: active or removed', 'approval-status': 'Approval filter: pending, approved, or rejected', search: 'IP prefix search', view: 'List view: all or operational', reason: 'Audit reason for unblock', json: 'Output as JSON' },
|
|
417
|
+
flags: { app: 'Scope to app key', env: 'Scope to environment', environment: 'Alias for --env', page: 'Page number', limit: 'Max results', status: 'Entry status: active or removed', 'approval-status': 'Approval filter: pending, approved, or rejected', search: 'IP prefix search', view: 'List view: all or operational', reason: 'Audit reason for unblock', route: 'Route pattern such as /admin*', path: 'Alias for --route', pattern: 'Alias for --route', mode: 'Route match mode: prefix, exact, or regex', 'path-mode': 'Alias for --mode', method: 'HTTP method, or ALL', duration: 'Expiry, e.g. 24h or 7d', json: 'Output as JSON' },
|
|
383
418
|
sub: {
|
|
384
419
|
list: { desc: 'List blocked IPs', run: (a, f) => require('./cli/security').blocklistList(a, f) },
|
|
385
|
-
add: { desc: 'Block an IP', usage: 'securenow blocklist add <ip> [--app <key>] [--env production] [--reason <reason>]', run: (a, f) => require('./cli/security').blocklistAdd(a, f) },
|
|
420
|
+
add: { desc: 'Block an IP', usage: 'securenow blocklist add <ip> [--route /admin*] [--mode prefix] [--method GET] [--app <key>] [--env production] [--duration 24h] [--reason <reason>]', run: (a, f) => require('./cli/security').blocklistAdd(a, f) },
|
|
386
421
|
unblock: { desc: 'Unblock an IP and keep block history', usage: 'securenow blocklist unblock <id> [--reason <reason>]', run: (a, f) => require('./cli/security').blocklistRemove(a, f) },
|
|
387
422
|
remove: { desc: 'Unblock an IP (compatibility alias)', usage: 'securenow blocklist remove <id> [--reason <reason>]', run: (a, f) => require('./cli/security').blocklistRemove(a, f) },
|
|
388
423
|
stats: { desc: 'Blocklist statistics', run: (a, f) => require('./cli/security').blocklistStats(a, f) },
|
package/firewall-only.js
CHANGED
|
@@ -17,10 +17,9 @@ try { require('dotenv').config({ quiet: true }); } catch (_) {}
|
|
|
17
17
|
const appConfig = require('./app-config');
|
|
18
18
|
const firewallOptions = appConfig.resolveFirewallOptions();
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
if (firewallOptions.apiKey && firewallOptions.enabled) {
|
|
20
|
+
// Runtime enable/disable is controlled by the dashboard/API toggle. If a
|
|
21
|
+
// firewall key is present, start sync so the remote toggle can be applied.
|
|
22
|
+
if (firewallOptions.apiKey) {
|
|
24
23
|
require('./firewall').init({
|
|
25
24
|
apiKey: firewallOptions.apiKey,
|
|
26
25
|
appKey: firewallOptions.appKey,
|
package/firewall.js
CHANGED
|
@@ -17,6 +17,7 @@ let _initialized = false;
|
|
|
17
17
|
let _consecutiveErrors = 0;
|
|
18
18
|
let _layers = [];
|
|
19
19
|
let _rawIps = [];
|
|
20
|
+
let _blocklistRules = [];
|
|
20
21
|
let _stats = { syncs: 0, blocked: 0, rateLimited: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
21
22
|
let _localhostFallbackTried = false;
|
|
22
23
|
let _eventQueue = [];
|
|
@@ -37,8 +38,8 @@ let _lastAllowlistModified = null;
|
|
|
37
38
|
let _lastAllowlistVersion = null;
|
|
38
39
|
let _lastAllowlistSyncEtag = null;
|
|
39
40
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
41
|
+
// Route/method-scoped policy state. Global IP/CIDR blocks stay in _matcher so
|
|
42
|
+
// TCP, OS firewall, and Cloud WAF layers never over-enforce an HTTP-only rule.
|
|
42
43
|
let _rateLimitRules = [];
|
|
43
44
|
let _lastRateLimitVersion = null;
|
|
44
45
|
let _rateLimitBuckets = new Map();
|
|
@@ -322,9 +323,44 @@ function reportFirewallEvent(event) {
|
|
|
322
323
|
if (_eventQueue.length >= EVENT_BATCH_SIZE) flushFirewallEvents();
|
|
323
324
|
else scheduleEventFlush();
|
|
324
325
|
}
|
|
325
|
-
|
|
326
|
+
|
|
327
|
+
function normalizePrefixPattern(pattern) {
|
|
328
|
+
const value = String(pattern || '');
|
|
329
|
+
return value.endsWith('*') ? value.slice(0, -1) : value;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeBlocklistRules(rules) {
|
|
333
|
+
if (!Array.isArray(rules)) return [];
|
|
334
|
+
return rules
|
|
335
|
+
.map((rule) => {
|
|
336
|
+
if (!rule || !rule.ip) return null;
|
|
337
|
+
const pathMatchMode = ['exact', 'prefix', 'regex'].includes(String(rule.pathMatchMode || '').toLowerCase())
|
|
338
|
+
? String(rule.pathMatchMode).toLowerCase()
|
|
339
|
+
: 'prefix';
|
|
340
|
+
return {
|
|
341
|
+
...rule,
|
|
342
|
+
ip: String(rule.ip || '').trim(),
|
|
343
|
+
method: String(rule.method || 'ALL').toUpperCase(),
|
|
344
|
+
pathPattern: pathMatchMode === 'prefix'
|
|
345
|
+
? normalizePrefixPattern(rule.pathPattern)
|
|
346
|
+
: String(rule.pathPattern || '').trim(),
|
|
347
|
+
pathMatchMode,
|
|
348
|
+
};
|
|
349
|
+
})
|
|
350
|
+
.filter(Boolean);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function setBlocklistData(ips, rules) {
|
|
354
|
+
const normalizedIps = Array.isArray(ips) ? ips : [];
|
|
355
|
+
_rawIps = normalizedIps;
|
|
356
|
+
_blocklistRules = normalizeBlocklistRules(rules);
|
|
357
|
+
_matcher = createMatcher(normalizedIps);
|
|
358
|
+
_stats.syncs++;
|
|
359
|
+
notifyLayers(normalizedIps);
|
|
360
|
+
}
|
|
361
|
+
|
|
326
362
|
// Unified Sync (v2 - single request for everything)
|
|
327
|
-
|
|
363
|
+
|
|
328
364
|
function doUnifiedSync(callback) {
|
|
329
365
|
const query = new URLSearchParams();
|
|
330
366
|
if (_options.appKey) query.set('app', _options.appKey);
|
|
@@ -389,13 +425,10 @@ function doUnifiedSync(callback) {
|
|
|
389
425
|
}
|
|
390
426
|
}
|
|
391
427
|
|
|
392
|
-
if (body.blocklistIps) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
notifyLayers(body.blocklistIps);
|
|
397
|
-
blChanged = true;
|
|
398
|
-
}
|
|
428
|
+
if (body.blocklistIps || body.blocklistRules) {
|
|
429
|
+
setBlocklistData(body.blocklistIps || [], body.blocklistRules || []);
|
|
430
|
+
blChanged = true;
|
|
431
|
+
}
|
|
399
432
|
|
|
400
433
|
// Update allowlist version + data
|
|
401
434
|
if (body.allowlist) {
|
|
@@ -445,16 +478,13 @@ function legacyBlocklistSync(callback) {
|
|
|
445
478
|
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
446
479
|
if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
447
480
|
|
|
448
|
-
try {
|
|
449
|
-
const body = JSON.parse(data);
|
|
450
|
-
const ips = body.ips || [];
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
_stats.syncs++;
|
|
456
|
-
notifyLayers(ips);
|
|
457
|
-
callback(null, true, _matcher.stats());
|
|
481
|
+
try {
|
|
482
|
+
const body = JSON.parse(data);
|
|
483
|
+
const ips = body.ips || [];
|
|
484
|
+
setBlocklistData(ips, body.rules || body.blocklistRules || []);
|
|
485
|
+
_lastModified = res.headers['last-modified'] || null;
|
|
486
|
+
if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
|
|
487
|
+
callback(null, true, _matcher.stats());
|
|
458
488
|
} catch (e) {
|
|
459
489
|
callback(new Error(`Failed to parse blocklist: ${e.message}`));
|
|
460
490
|
}
|
|
@@ -612,16 +642,17 @@ function pollOnce(callback) {
|
|
|
612
642
|
_consecutiveErrors = 0;
|
|
613
643
|
resetCircuit();
|
|
614
644
|
if (result) {
|
|
615
|
-
if (result.blChanged && _options.log && _matcher) {
|
|
616
|
-
const s = _matcher.stats();
|
|
617
|
-
fwLog('[securenow] Firewall: re-synced %d blocked IPs (%d exact + %d CIDR ranges)
|
|
618
|
-
|
|
645
|
+
if (result.blChanged && _options.log && _matcher) {
|
|
646
|
+
const s = _matcher.stats();
|
|
647
|
+
fwLog('[securenow] Firewall: re-synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
|
|
648
|
+
s.total, s.exact, s.cidr, _blocklistRules.length);
|
|
649
|
+
}
|
|
619
650
|
if (result.alChanged && _options.log && _allowlistMatcher) {
|
|
620
651
|
const s = _allowlistMatcher.stats();
|
|
621
652
|
fwLog('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
622
653
|
}
|
|
623
654
|
if (result.rlChanged && _options.log) {
|
|
624
|
-
fwLog('[securenow] Firewall: re-synced %d rate-limit rules
|
|
655
|
+
fwLog('[securenow] Firewall: re-synced %d rate-limit rules', _rateLimitRules.length);
|
|
625
656
|
}
|
|
626
657
|
}
|
|
627
658
|
callback(null);
|
|
@@ -701,17 +732,18 @@ function startSyncLoop() {
|
|
|
701
732
|
return;
|
|
702
733
|
}
|
|
703
734
|
|
|
704
|
-
_initialized = true;
|
|
705
|
-
if (_options.log && _matcher) {
|
|
706
|
-
const s = _matcher.stats();
|
|
707
|
-
fwLog('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)
|
|
708
|
-
|
|
735
|
+
_initialized = true;
|
|
736
|
+
if (_options.log && _matcher) {
|
|
737
|
+
const s = _matcher.stats();
|
|
738
|
+
fwLog('[securenow] Firewall: synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
|
|
739
|
+
s.total, s.exact, s.cidr, _blocklistRules.length);
|
|
740
|
+
}
|
|
709
741
|
if (_options.log && _allowlistMatcher) {
|
|
710
742
|
const s = _allowlistMatcher.stats();
|
|
711
743
|
if (s.total > 0) fwLog('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
712
744
|
}
|
|
713
745
|
if (_options.log && _rateLimitRules.length > 0) {
|
|
714
|
-
fwLog('[securenow] Firewall: synced %d rate-limit rules
|
|
746
|
+
fwLog('[securenow] Firewall: synced %d rate-limit rules', _rateLimitRules.length);
|
|
715
747
|
}
|
|
716
748
|
});
|
|
717
749
|
}
|
|
@@ -859,7 +891,7 @@ function rulePathMatches(rule, path) {
|
|
|
859
891
|
if (mode === 'regex') {
|
|
860
892
|
try { return new RegExp(pattern).test(path); } catch (_) { return false; }
|
|
861
893
|
}
|
|
862
|
-
return path.startsWith(pattern);
|
|
894
|
+
return path.startsWith(normalizePrefixPattern(pattern));
|
|
863
895
|
}
|
|
864
896
|
|
|
865
897
|
function rateLimitKey(rule, ip) {
|
|
@@ -868,6 +900,28 @@ function rateLimitKey(rule, ip) {
|
|
|
868
900
|
return `${id}|${subject}`;
|
|
869
901
|
}
|
|
870
902
|
|
|
903
|
+
function checkBlocklistRules(req, ip) {
|
|
904
|
+
if (!_blocklistRules || _blocklistRules.length === 0) return null;
|
|
905
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
906
|
+
const path = requestPath(req);
|
|
907
|
+
|
|
908
|
+
for (const rule of _blocklistRules) {
|
|
909
|
+
if (!rule) continue;
|
|
910
|
+
const ruleMethod = String(rule.method || 'ALL').toUpperCase();
|
|
911
|
+
if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
|
|
912
|
+
if (!ruleIpMatches(rule, ip)) continue;
|
|
913
|
+
if (!rulePathMatches(rule, path)) continue;
|
|
914
|
+
return {
|
|
915
|
+
rule,
|
|
916
|
+
ruleId: rule.id || rule._id || '',
|
|
917
|
+
matchedEntry: rule.ip || ip,
|
|
918
|
+
path,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
|
|
871
925
|
function checkRateLimitRules(req, ip) {
|
|
872
926
|
if (!_rateLimitRules || _rateLimitRules.length === 0) return null;
|
|
873
927
|
const method = String(req.method || 'GET').toUpperCase();
|
|
@@ -963,6 +1017,24 @@ function firewallRequestHandler(req, res) {
|
|
|
963
1017
|
return true;
|
|
964
1018
|
}
|
|
965
1019
|
|
|
1020
|
+
const blockRuleDecision = checkBlocklistRules(req, ip);
|
|
1021
|
+
if (blockRuleDecision) {
|
|
1022
|
+
_stats.blocked++;
|
|
1023
|
+
if (_options && _options.log) {
|
|
1024
|
+
fwLog('[securenow] Firewall: blocked %s via HTTP (rule=%s)', ip, blockRuleDecision.ruleId || 'scoped');
|
|
1025
|
+
}
|
|
1026
|
+
reportFirewallEvent({
|
|
1027
|
+
source: 'blocklist',
|
|
1028
|
+
ip,
|
|
1029
|
+
matchedEntry: blockRuleDecision.matchedEntry || ip,
|
|
1030
|
+
method: req.method || '',
|
|
1031
|
+
path: blockRuleDecision.path || req.url || '',
|
|
1032
|
+
userAgent: req.headers['user-agent'] || '',
|
|
1033
|
+
});
|
|
1034
|
+
sendBlockResponse(req, res, ip);
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
966
1038
|
const rateLimitDecision = checkRateLimitRules(req, ip);
|
|
967
1039
|
if (rateLimitDecision) {
|
|
968
1040
|
_stats.rateLimited++;
|
|
@@ -1094,6 +1166,7 @@ function shutdown() {
|
|
|
1094
1166
|
_pollInflight = false;
|
|
1095
1167
|
_retryAfterUntil = 0;
|
|
1096
1168
|
_localhostFallbackTried = false;
|
|
1169
|
+
_blocklistRules = [];
|
|
1097
1170
|
_rateLimitRules = [];
|
|
1098
1171
|
_rateLimitBuckets = new Map();
|
|
1099
1172
|
_remainingApiUrlFallbacks = [];
|
|
@@ -1119,8 +1192,9 @@ function shutdown() {
|
|
|
1119
1192
|
|
|
1120
1193
|
function getStats() {
|
|
1121
1194
|
return {
|
|
1122
|
-
..._stats,
|
|
1195
|
+
..._stats,
|
|
1123
1196
|
matcher: _matcher ? _matcher.stats() : null,
|
|
1197
|
+
blocklistRules: _blocklistRules.length,
|
|
1124
1198
|
allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
|
|
1125
1199
|
rateLimitRules: _rateLimitRules.length,
|
|
1126
1200
|
rateLimitBuckets: _rateLimitBuckets.size,
|
|
@@ -1139,7 +1213,8 @@ function getStats() {
|
|
|
1139
1213
|
// as "no IPs to block" without us mutating the cached matcher.
|
|
1140
1214
|
function getMatcher() { return _remoteEnabled === false ? null : _matcher; }
|
|
1141
1215
|
function getAllowlistMatcher() { return _remoteEnabled === false ? null : _allowlistMatcher; }
|
|
1216
|
+
function getBlocklistRules() { return _remoteEnabled === false ? [] : _blocklistRules.slice(); }
|
|
1142
1217
|
function getRateLimitRules() { return _remoteEnabled === false ? [] : _rateLimitRules.slice(); }
|
|
1143
1218
|
function isRemoteEnabled() { return _remoteEnabled !== false; }
|
|
1144
1219
|
|
|
1145
|
-
module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getRateLimitRules, isRemoteEnabled };
|
|
1220
|
+
module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, isRemoteEnabled };
|