securenow 7.6.4 → 7.6.6
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 +0 -1
- package/SKILL-API.md +3 -4
- package/SKILL-CLI.md +39 -14
- package/app-config.js +10 -2
- package/cli/auth.js +2 -2
- package/cli/config.js +20 -5
- package/cli/credentials.js +5 -1
- package/cli/human.js +366 -0
- package/cli/init.js +1 -1
- package/cli.js +38 -10
- package/docs/ENVIRONMENT-VARIABLES.md +1 -1
- package/docs/FIREWALL-GUIDE.md +16 -15
- package/firewall.js +131 -27
- package/mcp/catalog.js +163 -0
- package/package.json +1 -1
package/NPM_README.md
CHANGED
|
@@ -1236,7 +1236,6 @@ Legacy env fallback aliases are listed below for existing installs only.
|
|
|
1236
1236
|
|----------|-------------|---------|
|
|
1237
1237
|
| `SECURENOW_API_KEY` | Legacy firewall key override. Prefer `apiKey` in `.securenow/credentials.json`. | from creds file |
|
|
1238
1238
|
| `SECURENOW_API_URL` | SecureNow API base URL. Auto-detected for co-located deployments (falls back to `http://localhost:4000` on ECONNREFUSED). | `https://api.securenow.ai` |
|
|
1239
|
-
| `SECURENOW_FIREWALL_ENABLED` | Master kill-switch. Set to `0` to disable. | `1` |
|
|
1240
1239
|
| `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between version checks (lightweight ETag-based). | `10` |
|
|
1241
1240
|
| `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds (safety net). | `300` |
|
|
1242
1241
|
| `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow when unavailable) or `closed` (block all). | `open` |
|
package/SKILL-API.md
CHANGED
|
@@ -503,10 +503,9 @@ Local development and production use `.securenow/credentials.json`. Every settin
|
|
|
503
503
|
| Variable | Description | Default |
|
|
504
504
|
|----------|-------------|---------|
|
|
505
505
|
| `SECURENOW_API_KEY` | Legacy env override for the `apiKey` field (`snk_live_...`). Since v7.5.1, login writes the scoped firewall key to `.securenow/credentials.json`. | - |
|
|
506
|
-
| `SECURENOW_API_URL` | SecureNow API base URL | `https://api.securenow.ai` |
|
|
507
|
-
| `
|
|
508
|
-
| `
|
|
509
|
-
| `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds | `300` |
|
|
506
|
+
| `SECURENOW_API_URL` | SecureNow API base URL | `https://api.securenow.ai` |
|
|
507
|
+
| `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between lightweight version checks | `10` |
|
|
508
|
+
| `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds | `300` |
|
|
510
509
|
| `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow all when unavailable) or `closed` | `open` |
|
|
511
510
|
| `SECURENOW_FIREWALL_STATUS_CODE` | HTTP status for blocked requests | `403` |
|
|
512
511
|
| `SECURENOW_FIREWALL_LOG` | Log blocked requests | `1` |
|
package/SKILL-CLI.md
CHANGED
|
@@ -203,20 +203,45 @@ securenow logs list --app my-app --env production --minutes 30 --level error
|
|
|
203
203
|
securenow logs trace <traceId> --env production # logs correlated to a specific trace
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
### Notifications
|
|
207
|
-
|
|
208
|
-
```bash
|
|
209
|
-
securenow notifications [--limit N] [--page P]
|
|
210
|
-
securenow notifications list --limit 20
|
|
211
|
-
securenow notifications read <id> # mark one as read
|
|
212
|
-
securenow notifications read-all # mark all as read
|
|
213
|
-
securenow notifications unread # unread count
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
###
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
206
|
+
### Notifications
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
securenow notifications [--limit N] [--page P]
|
|
210
|
+
securenow notifications list --limit 20
|
|
211
|
+
securenow notifications read <id> # mark one as read
|
|
212
|
+
securenow notifications read-all # mark all as read
|
|
213
|
+
securenow notifications unread # unread count
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Human Actions — AI-Prepared Decision Queue
|
|
217
|
+
|
|
218
|
+
Use this for the same work shown in **Requires Human**: AI has already grouped alerts, fetched traces, built a report, and left a final Block IP or False Positive decision.
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
securenow human # list human decisions, most urgent first
|
|
222
|
+
securenow human list --limit 20
|
|
223
|
+
securenow human show 1 # inspect row 1 with AI report, DAG, proofs, trace links
|
|
224
|
+
securenow human block 1 --yes --reason "AI evidence confirmed malicious"
|
|
225
|
+
securenow human fp 1 --yes --reason "Scoped false positive after evidence review"
|
|
226
|
+
securenow human prompt 1 # print a Codex/Claude MCP prompt for this row
|
|
227
|
+
securenow human work --limit 10 # list queue + print MCP runbook for deep queue work
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
MCP parity:
|
|
231
|
+
|
|
232
|
+
- `securenow_human_actions_list`
|
|
233
|
+
- `securenow_notifications_get`
|
|
234
|
+
- `securenow_human_action_report`
|
|
235
|
+
- `securenow_human_action_block`
|
|
236
|
+
- `securenow_human_action_false_positive`
|
|
237
|
+
- prompts: `investigate_human_action_row`, `work_human_actions`
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
### Alerts
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
securenow alerts # list alert rules (default)
|
|
220
245
|
securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
|
|
221
246
|
securenow alerts rules show <id> # one rule; JSON: --json
|
|
222
247
|
securenow alerts rules update <id> --applications-all # all current & future apps
|
package/app-config.js
CHANGED
|
@@ -131,7 +131,6 @@ const ENV_TO_CONFIG_PATH = Object.freeze({
|
|
|
131
131
|
SECURENOW_STRICT: 'runtime.strict',
|
|
132
132
|
SECURENOW_TEST_SPAN: 'runtime.testSpan',
|
|
133
133
|
SECURENOW_HIDE_BANNER: 'runtime.hideBanner',
|
|
134
|
-
SECURENOW_FIREWALL_ENABLED: 'firewall.enabled',
|
|
135
134
|
SECURENOW_API_URL: 'firewall.apiUrl',
|
|
136
135
|
SECURENOW_FIREWALL_VERSION_INTERVAL: 'firewall.versionCheckInterval',
|
|
137
136
|
SECURENOW_FIREWALL_SYNC_INTERVAL: 'firewall.syncInterval',
|
|
@@ -548,6 +547,7 @@ function resolveEnvKey(key) {
|
|
|
548
547
|
if (upper === 'OTEL_SERVICE_NAME') return resolveAppName();
|
|
549
548
|
if (upper === 'SECURENOW_API_KEY') return resolveApiKey();
|
|
550
549
|
if (upper === 'SECURENOW_INSTANCE' || upper === 'OTEL_EXPORTER_OTLP_ENDPOINT') return resolveInstance();
|
|
550
|
+
if (upper === 'SECURENOW_FIREWALL_ENABLED') return resolveFirewallEnabled();
|
|
551
551
|
|
|
552
552
|
const configPath = ENV_TO_CONFIG_PATH[upper];
|
|
553
553
|
if (configPath) return resolveConfigPath(configPath);
|
|
@@ -597,12 +597,19 @@ function resolveEndpoints(options = {}) {
|
|
|
597
597
|
};
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
function resolveFirewallEnabled() {
|
|
601
|
+
const fromConfig = pick(getPath(loadCredentials()?.config, 'firewall.enabled'));
|
|
602
|
+
if (fromConfig != null) return parseBool(fromConfig, true);
|
|
603
|
+
|
|
604
|
+
return parseBool(getPath(DEFAULT_CONFIG, 'firewall.enabled'), true);
|
|
605
|
+
}
|
|
606
|
+
|
|
600
607
|
function resolveFirewallOptions() {
|
|
601
608
|
return {
|
|
602
609
|
apiKey: resolveApiKey(),
|
|
603
610
|
appKey: resolveAppKey() || null,
|
|
604
611
|
environment: resolveDeploymentEnvironment(),
|
|
605
|
-
enabled:
|
|
612
|
+
enabled: resolveFirewallEnabled(),
|
|
606
613
|
apiUrl: env('SECURENOW_API_URL') || DEFAULT_API_URL,
|
|
607
614
|
versionCheckInterval: numberEnv('SECURENOW_FIREWALL_VERSION_INTERVAL', 10, 1),
|
|
608
615
|
syncInterval: numberEnv('SECURENOW_FIREWALL_SYNC_INTERVAL', 300, 1),
|
|
@@ -651,6 +658,7 @@ module.exports = {
|
|
|
651
658
|
resolveOtlpHeaders,
|
|
652
659
|
resolveOtlpHeaderString,
|
|
653
660
|
resolveEndpoints,
|
|
661
|
+
resolveFirewallEnabled,
|
|
654
662
|
resolveFirewallOptions,
|
|
655
663
|
env,
|
|
656
664
|
boolEnv,
|
package/cli/auth.js
CHANGED
|
@@ -237,7 +237,7 @@ async function login(args, flags) {
|
|
|
237
237
|
const email = payload?.email || 'unknown';
|
|
238
238
|
const exp = payload?.exp ? payload.exp * 1000 : null;
|
|
239
239
|
|
|
240
|
-
config.setAuth(token, email, exp, { local });
|
|
240
|
+
config.setAuth(token, email, exp, { local, enableFirewall: true });
|
|
241
241
|
if (local) config.ensureLocalGitignore();
|
|
242
242
|
console.log('');
|
|
243
243
|
ui.success(`Logged in as ${ui.c.bold(email)}`);
|
|
@@ -255,7 +255,7 @@ async function login(args, flags) {
|
|
|
255
255
|
const email = payload?.email || 'unknown';
|
|
256
256
|
const exp = payload?.exp ? payload.exp * 1000 : null;
|
|
257
257
|
|
|
258
|
-
config.setAuth(token, email, exp, { local, app });
|
|
258
|
+
config.setAuth(token, email, exp, { local, app, enableFirewall: true });
|
|
259
259
|
if (apiKey) config.setApiKey(apiKey, { local });
|
|
260
260
|
if (local) config.ensureLocalGitignore();
|
|
261
261
|
console.log('');
|
package/cli/config.js
CHANGED
|
@@ -101,11 +101,22 @@ function saveCredentials(creds, { local = false } = {}) {
|
|
|
101
101
|
saveJSON(targetFile, appConfig.withCredentialDefaults(creds) || {});
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
function
|
|
104
|
+
function withOnboardingFirewallEnabled(creds) {
|
|
105
|
+
const payload = appConfig.withCredentialDefaults(creds || {}) || {};
|
|
106
|
+
payload.config = payload.config || {};
|
|
107
|
+
payload.config.firewall = payload.config.firewall || {};
|
|
108
|
+
payload.config.firewall.enabled = true;
|
|
109
|
+
return payload;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureCredentialDefaults({ local, enableFirewall = false } = {}) {
|
|
105
113
|
const useLocal = local === true || (local == null && hasLocalCredentials());
|
|
106
114
|
const targetFile = credentialsFileForLocal(useLocal);
|
|
107
115
|
const existing = loadJSON(targetFile);
|
|
108
|
-
|
|
116
|
+
const payload = enableFirewall
|
|
117
|
+
? withOnboardingFirewallEnabled(existing || {})
|
|
118
|
+
: appConfig.withCredentialDefaults(existing || {}) || {};
|
|
119
|
+
saveJSON(targetFile, payload);
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
function clearCredentials({ local } = {}) {
|
|
@@ -132,7 +143,7 @@ function getToken() {
|
|
|
132
143
|
return creds.token;
|
|
133
144
|
}
|
|
134
145
|
|
|
135
|
-
function setAuth(token, email, expiresAt, { local = false, app = null } = {}) {
|
|
146
|
+
function setAuth(token, email, expiresAt, { local = false, app = null, enableFirewall = false } = {}) {
|
|
136
147
|
const targetFile = credentialsFileForLocal(local);
|
|
137
148
|
const payload = { ...loadJSON(targetFile), token, email, expiresAt };
|
|
138
149
|
if (app && (app.key || app.name || app.instance)) {
|
|
@@ -142,7 +153,10 @@ function setAuth(token, email, expiresAt, { local = false, app = null } = {}) {
|
|
|
142
153
|
instance: app.instance || null,
|
|
143
154
|
};
|
|
144
155
|
}
|
|
145
|
-
|
|
156
|
+
saveJSON(
|
|
157
|
+
targetFile,
|
|
158
|
+
enableFirewall ? withOnboardingFirewallEnabled(payload) : appConfig.withCredentialDefaults(payload) || {}
|
|
159
|
+
);
|
|
146
160
|
}
|
|
147
161
|
|
|
148
162
|
function getApp() {
|
|
@@ -154,7 +168,7 @@ function setApiKey(apiKey, { local } = {}) {
|
|
|
154
168
|
const useLocal = local === true || (local == null && hasLocalCredentials());
|
|
155
169
|
const targetFile = credentialsFileForLocal(useLocal);
|
|
156
170
|
const existing = loadJSON(targetFile);
|
|
157
|
-
saveJSON(targetFile,
|
|
171
|
+
saveJSON(targetFile, withOnboardingFirewallEnabled({ ...existing, apiKey }));
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
function clearApiKey({ local } = {}) {
|
|
@@ -238,6 +252,7 @@ module.exports = {
|
|
|
238
252
|
getAuthSource,
|
|
239
253
|
hasLocalCredentials,
|
|
240
254
|
ensureCredentialDefaults,
|
|
255
|
+
withOnboardingFirewallEnabled,
|
|
241
256
|
ensureLocalGitignore,
|
|
242
257
|
getApiUrl,
|
|
243
258
|
getAppUrl,
|
package/cli/credentials.js
CHANGED
|
@@ -20,7 +20,7 @@ function buildRuntimeCredentials(options = {}) {
|
|
|
20
20
|
options.env ||
|
|
21
21
|
appConfig.resolveDeploymentEnvironment() ||
|
|
22
22
|
'production';
|
|
23
|
-
const runtime =
|
|
23
|
+
const runtime = config.withOnboardingFirewallEnabled({
|
|
24
24
|
apiKey: creds.apiKey || null,
|
|
25
25
|
app: {
|
|
26
26
|
key: creds.app?.key || null,
|
|
@@ -33,6 +33,10 @@ function buildRuntimeCredentials(options = {}) {
|
|
|
33
33
|
...(creds.config?.runtime || {}),
|
|
34
34
|
deploymentEnvironment,
|
|
35
35
|
},
|
|
36
|
+
firewall: {
|
|
37
|
+
...(creds.config?.firewall || {}),
|
|
38
|
+
enabled: true,
|
|
39
|
+
},
|
|
36
40
|
},
|
|
37
41
|
_securenow: {
|
|
38
42
|
...(creds._securenow || {}),
|
package/cli/human.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('./config');
|
|
4
|
+
const { api, requireAuth } = require('./client');
|
|
5
|
+
const ui = require('./ui');
|
|
6
|
+
|
|
7
|
+
function appUrl(pathname) {
|
|
8
|
+
return `${config.getAppUrl()}${pathname}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function asArray(value) {
|
|
12
|
+
return Array.isArray(value) ? value : [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatList(values, limit = 3) {
|
|
16
|
+
const list = asArray(values).filter(Boolean).map(String);
|
|
17
|
+
if (!list.length) return '-';
|
|
18
|
+
return list.slice(0, limit).join(', ') + (list.length > limit ? ` +${list.length - limit}` : '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function confidenceLabel(value) {
|
|
22
|
+
if (value === null || value === undefined) return 'AI ready';
|
|
23
|
+
return `${Math.round(Number(value))}%`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function taskLabel(task) {
|
|
27
|
+
return task?.headline || task?.title || task?.detectionName || 'AI investigation';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseTaskRef(ref) {
|
|
31
|
+
if (!ref) return null;
|
|
32
|
+
const text = String(ref).trim();
|
|
33
|
+
if (/^\d+$/.test(text)) return { kind: 'row', row: Math.max(1, parseInt(text, 10)) };
|
|
34
|
+
const colon = text.indexOf(':');
|
|
35
|
+
if (colon > 0) {
|
|
36
|
+
return { kind: 'id', notificationId: text.slice(0, colon), ip: text.slice(colon + 1) };
|
|
37
|
+
}
|
|
38
|
+
const slash = text.indexOf('/');
|
|
39
|
+
if (slash > 0) {
|
|
40
|
+
return { kind: 'id', notificationId: text.slice(0, slash), ip: text.slice(slash + 1) };
|
|
41
|
+
}
|
|
42
|
+
return { kind: 'notification', notificationId: text };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fetchQueue(flags = {}) {
|
|
46
|
+
const query = {
|
|
47
|
+
page: flags.page || 1,
|
|
48
|
+
limit: flags.limit || 20,
|
|
49
|
+
};
|
|
50
|
+
if (flags.search) query.search = flags.search;
|
|
51
|
+
return api.get('/notifications/approval-tasks', { query });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function resolveTask(ref, flags = {}) {
|
|
55
|
+
const parsed = parseTaskRef(ref);
|
|
56
|
+
if (!parsed) {
|
|
57
|
+
ui.error('Task row or id required. Use `securenow human list` first.');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const queue = await fetchQueue({ ...flags, limit: flags.limit || 50 });
|
|
62
|
+
const tasks = queue.tasks || [];
|
|
63
|
+
|
|
64
|
+
if (parsed.kind === 'row') {
|
|
65
|
+
const task = tasks[parsed.row - 1];
|
|
66
|
+
if (!task) {
|
|
67
|
+
ui.error(`Row ${parsed.row} was not found on page ${flags.page || 1}.`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
return { task, queue, row: parsed.row };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const task = tasks.find((candidate) => (
|
|
74
|
+
candidate.id === ref ||
|
|
75
|
+
(candidate.notificationId === parsed.notificationId && (!parsed.ip || candidate.ip === parsed.ip))
|
|
76
|
+
));
|
|
77
|
+
|
|
78
|
+
if (task) return { task, queue, row: tasks.indexOf(task) + 1 };
|
|
79
|
+
|
|
80
|
+
if (parsed.kind === 'id' && parsed.notificationId && parsed.ip) {
|
|
81
|
+
return {
|
|
82
|
+
task: {
|
|
83
|
+
id: `${parsed.notificationId}:${parsed.ip}`,
|
|
84
|
+
notificationId: parsed.notificationId,
|
|
85
|
+
ip: parsed.ip,
|
|
86
|
+
href: `/dashboard/notifications/ip/${parsed.notificationId}/${encodeURIComponent(parsed.ip)}`,
|
|
87
|
+
},
|
|
88
|
+
queue,
|
|
89
|
+
row: null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ui.error(`Could not find task "${ref}". Use row number or <notificationId>:<ip>.`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function list(args, flags) {
|
|
98
|
+
requireAuth();
|
|
99
|
+
const s = ui.spinner('Fetching human action queue');
|
|
100
|
+
try {
|
|
101
|
+
const data = await fetchQueue(flags);
|
|
102
|
+
const tasks = data.tasks || [];
|
|
103
|
+
s.stop(`Found ${data.summary?.total ?? data.pagination?.total ?? tasks.length} human action${tasks.length === 1 ? '' : 's'}`);
|
|
104
|
+
|
|
105
|
+
if (flags.json) {
|
|
106
|
+
ui.json(data);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('');
|
|
111
|
+
if (!tasks.length) {
|
|
112
|
+
ui.info('No human decisions waiting.');
|
|
113
|
+
console.log('');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rows = tasks.map((task, index) => [
|
|
118
|
+
String(index + 1),
|
|
119
|
+
task.urgencyScore || 0,
|
|
120
|
+
ui.statusBadge(task.severity || 'info'),
|
|
121
|
+
task.ip || '-',
|
|
122
|
+
confidenceLabel(task.aiDecision?.confidence),
|
|
123
|
+
ui.truncate(formatList(task.proofs?.paths, 2), 28),
|
|
124
|
+
ui.truncate(taskLabel(task), 56),
|
|
125
|
+
task.lastSeenAt ? ui.timeAgo(task.lastSeenAt) : '-',
|
|
126
|
+
]);
|
|
127
|
+
ui.table(['Row', 'Urgency', 'Severity', 'IP', 'Conf', 'Paths', 'Case', 'Seen'], rows, { maxColWidth: 60 });
|
|
128
|
+
console.log('');
|
|
129
|
+
ui.info('Inspect a row: securenow human show <row>');
|
|
130
|
+
ui.info('Use MCP deeply: securenow human prompt <row>');
|
|
131
|
+
console.log('');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
s.fail('Failed to fetch human action queue');
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function printTaskSummary(task, row) {
|
|
139
|
+
ui.heading(row ? `Human action row ${row}` : 'Human action');
|
|
140
|
+
ui.keyValue([
|
|
141
|
+
['Task ID', task.id || `${task.notificationId}:${task.ip}`],
|
|
142
|
+
['IP', task.ip || '-'],
|
|
143
|
+
['Case', taskLabel(task)],
|
|
144
|
+
['Severity', task.severity || '-'],
|
|
145
|
+
['Urgency', String(task.urgencyScore || 0)],
|
|
146
|
+
['AI confidence', confidenceLabel(task.aiDecision?.confidence)],
|
|
147
|
+
['Recommendation', task.aiDecision?.label || task.aiDecision?.recommendation || 'Review'],
|
|
148
|
+
['Dashboard', appUrl(task.href || `/dashboard/notifications/ip/${task.notificationId}/${encodeURIComponent(task.ip || '')}`)],
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function printAiReport(ipReport = {}, notification = {}) {
|
|
153
|
+
const ip = ipReport.ip || {};
|
|
154
|
+
const report = ip.aiReport || {};
|
|
155
|
+
const raw = report.rawData || {};
|
|
156
|
+
const steps = asArray(report.steps);
|
|
157
|
+
const finalDecision = report.finalDecision || {};
|
|
158
|
+
|
|
159
|
+
ui.subheading('AI report');
|
|
160
|
+
ui.keyValue([
|
|
161
|
+
['Status', report.status || '-'],
|
|
162
|
+
['Verdict', ip.verdict || raw.verdict || '-'],
|
|
163
|
+
['Risk', ip.riskScore != null ? `${ip.riskScore}/100` : raw.riskScore != null ? `${raw.riskScore}/100` : '-'],
|
|
164
|
+
['Final decision', finalDecision.decision || '-'],
|
|
165
|
+
['Final reason', ui.truncate(finalDecision.reason || report.summary || '-', 120)],
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
if (report.summary) {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(` ${ui.c.bold('Summary')} ${report.summary}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (asArray(report.findings).length) {
|
|
174
|
+
console.log('');
|
|
175
|
+
console.log(` ${ui.c.bold('Findings')}`);
|
|
176
|
+
for (const finding of asArray(report.findings).slice(0, 6)) {
|
|
177
|
+
console.log(` - ${finding}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (steps.length) {
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(` ${ui.c.bold('DAG steps')}`);
|
|
184
|
+
const rows = steps.map((step) => [
|
|
185
|
+
step.id,
|
|
186
|
+
step.status || '-',
|
|
187
|
+
step.durationMs != null ? `${step.durationMs}ms` : '-',
|
|
188
|
+
ui.truncate(step.outputSummary || step.inputSummary || step.error || '', 80),
|
|
189
|
+
]);
|
|
190
|
+
ui.table(['Step', 'Status', 'Dur', 'Output'], rows);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const traceIds = [
|
|
194
|
+
...asArray(ip.metadata?.traceIds),
|
|
195
|
+
...steps.flatMap((step) => asArray(step.data?.traceIds)),
|
|
196
|
+
...steps.flatMap((step) => asArray(step.data?.suspectTraceIds)),
|
|
197
|
+
].filter(Boolean);
|
|
198
|
+
const uniqueTraceIds = [...new Set(traceIds)].slice(0, 10);
|
|
199
|
+
if (uniqueTraceIds.length) {
|
|
200
|
+
const apps = asArray(notification.notification?.applications || notification.applications);
|
|
201
|
+
const appKeys = apps.join(',');
|
|
202
|
+
console.log('');
|
|
203
|
+
console.log(` ${ui.c.bold('Trace links')}`);
|
|
204
|
+
for (const traceId of uniqueTraceIds) {
|
|
205
|
+
const qs = appKeys ? `?appKeys=${encodeURIComponent(appKeys)}` : '';
|
|
206
|
+
console.log(` - ${traceId} ${ui.c.dim(appUrl(`/trace-details/${encodeURIComponent(traceId)}${qs}`))}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function show(args, flags) {
|
|
212
|
+
requireAuth();
|
|
213
|
+
const ref = args[0];
|
|
214
|
+
const s = ui.spinner('Fetching human action detail');
|
|
215
|
+
try {
|
|
216
|
+
const { task, row } = await resolveTask(ref, flags);
|
|
217
|
+
const [ipReport, notification] = await Promise.all([
|
|
218
|
+
api.get(`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}`),
|
|
219
|
+
api.get(`/notifications/${encodeURIComponent(task.notificationId)}`).catch(() => ({})),
|
|
220
|
+
]);
|
|
221
|
+
s.stop('Loaded human action detail');
|
|
222
|
+
|
|
223
|
+
if (flags.json) {
|
|
224
|
+
ui.json({ task, ipReport, notification });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
printTaskSummary(task, row);
|
|
229
|
+
printAiReport(ipReport, notification);
|
|
230
|
+
|
|
231
|
+
ui.subheading('Proofs');
|
|
232
|
+
ui.keyValue([
|
|
233
|
+
['Paths', formatList(task.proofs?.paths, 5)],
|
|
234
|
+
['Methods', formatList(task.proofs?.methods, 5)],
|
|
235
|
+
['Status', formatList((task.proofs?.statusCodes || []).map(String), 5)],
|
|
236
|
+
['User agents', formatList(task.proofs?.userAgents, 3)],
|
|
237
|
+
['Traces', formatList(task.proofs?.traceIds, 3)],
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
console.log('');
|
|
241
|
+
ui.info(`Approve block: securenow human block ${ref} --yes --reason "AI evidence confirmed malicious"`);
|
|
242
|
+
ui.info(`Mark FP: securenow human fp ${ref} --yes --reason "Scoped false positive after evidence review"`);
|
|
243
|
+
ui.info(`MCP prompt: securenow human prompt ${ref}`);
|
|
244
|
+
console.log('');
|
|
245
|
+
} catch (err) {
|
|
246
|
+
s.fail('Failed to fetch human action detail');
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function block(args, flags) {
|
|
252
|
+
requireAuth();
|
|
253
|
+
const ref = args[0];
|
|
254
|
+
const { task } = await resolveTask(ref, flags);
|
|
255
|
+
const reason = flags.reason || task.aiDecision?.reason || task.aiDecision?.verdict || 'Approved SecureNow AI block recommendation';
|
|
256
|
+
|
|
257
|
+
if (!flags.yes && !flags.force) {
|
|
258
|
+
const ok = await ui.confirm(`Block ${task.ip} based on AI evidence?`);
|
|
259
|
+
if (!ok) return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const s = ui.spinner(`Blocking ${task.ip}`);
|
|
263
|
+
try {
|
|
264
|
+
const body = {
|
|
265
|
+
status: 'blocked',
|
|
266
|
+
verdict: task.aiDecision?.verdict || 'Blocked after SecureNow AI approval',
|
|
267
|
+
riskScore: task.aiDecision?.riskScore ?? undefined,
|
|
268
|
+
decisionSource: 'ai_dag',
|
|
269
|
+
note: reason,
|
|
270
|
+
};
|
|
271
|
+
const data = await api.put(`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}/status`, body);
|
|
272
|
+
s.stop(`${task.ip} blocked`);
|
|
273
|
+
if (flags.json) ui.json(data);
|
|
274
|
+
else ui.success(`Handled: ${task.ip} blocked and recorded.`);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
s.fail('Failed to block IP');
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function fp(args, flags) {
|
|
282
|
+
requireAuth();
|
|
283
|
+
const ref = args[0];
|
|
284
|
+
const { task } = await resolveTask(ref, flags);
|
|
285
|
+
const scope = task.falsePositiveScope || {};
|
|
286
|
+
const reason = flags.reason || `Marked false positive from CLI. ${scope.scopeLabel || ''}`.trim();
|
|
287
|
+
|
|
288
|
+
if (!flags.yes && !flags.force) {
|
|
289
|
+
const ok = await ui.confirm(`Mark ${task.ip} as a scoped false positive?`);
|
|
290
|
+
if (!ok) return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const s = ui.spinner(`Marking ${task.ip} false positive`);
|
|
294
|
+
try {
|
|
295
|
+
const body = {
|
|
296
|
+
reason,
|
|
297
|
+
conditions: flags.conditions ? JSON.parse(flags.conditions) : (scope.conditions?.length ? scope.conditions : null),
|
|
298
|
+
matchMode: flags['match-mode'] || scope.matchMode || 'all',
|
|
299
|
+
createExclusion: flags['create-exclusion'] != null ? flags['create-exclusion'] !== 'false' : Boolean(scope.createExclusion),
|
|
300
|
+
applyToExisting: flags['apply-existing'] != null ? flags['apply-existing'] !== 'false' : Boolean(scope.applyToExisting),
|
|
301
|
+
ruleScope: flags['rule-scope'] || scope.ruleScope || 'this_rule',
|
|
302
|
+
aiConfidence: task.aiDecision?.confidence ?? null,
|
|
303
|
+
};
|
|
304
|
+
const data = await api.post(`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}/false-positive`, body);
|
|
305
|
+
s.stop(`${task.ip} marked false positive`);
|
|
306
|
+
if (flags.json) ui.json(data);
|
|
307
|
+
else ui.success(`Handled: ${task.ip} marked false positive with scoped evidence.`);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
s.fail('Failed to mark false positive');
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function mcpPromptText(ref, flags = {}) {
|
|
315
|
+
const rowText = ref ? `Start with human action row/id: ${ref}.` : 'Work the human action queue from most urgent to least urgent.';
|
|
316
|
+
const limit = flags.limit || 10;
|
|
317
|
+
const mode = flags.mode || (ref ? 'single_row' : 'queue');
|
|
318
|
+
return [
|
|
319
|
+
'Use the SecureNow MCP server to do my human Detect & Respond work deeply.',
|
|
320
|
+
'',
|
|
321
|
+
rowText,
|
|
322
|
+
`Mode: ${mode}. Review up to ${limit} task(s) unless I ask for more.`,
|
|
323
|
+
'',
|
|
324
|
+
'Runbook:',
|
|
325
|
+
'1. Call securenow_human_actions_list with page=1 and the requested limit.',
|
|
326
|
+
ref
|
|
327
|
+
? '2. Select the requested row number or task id, then call securenow_notifications_get and securenow_human_action_report for its notificationId and IP.'
|
|
328
|
+
: '2. For each task, call securenow_notifications_get and securenow_human_action_report for the notificationId and IP.',
|
|
329
|
+
'3. Read the AI report, finalDecision, DAG steps, findings, proofs, metadata paths/user agents/status codes, and trace IDs.',
|
|
330
|
+
'4. Open/inspect trace evidence with securenow_traces_show and securenow_logs_for_trace when trace IDs are available.',
|
|
331
|
+
'5. Decide one of only two outcomes: block the IP, or mark a scoped false positive. If evidence is ambiguous, report why and stop for that row.',
|
|
332
|
+
'6. For block decisions, call securenow_human_action_block with confirm:true and a precise reason.',
|
|
333
|
+
'7. For false positives, call securenow_human_action_false_positive with confirm:true, the narrowest available conditions, and a precise reason.',
|
|
334
|
+
'8. Summarize each row handled, skipped, and still waiting. Do not globally trust an IP by default.',
|
|
335
|
+
'',
|
|
336
|
+
'Safety:',
|
|
337
|
+
'- Do not call write tools without confirm:true and a reason.',
|
|
338
|
+
'- Do not use broad false-positive scopes if the evidence only supports one alert rule/path/method/status/user-agent/body pattern.',
|
|
339
|
+
'- Prefer no action over a weak action.',
|
|
340
|
+
].join('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function prompt(args, flags) {
|
|
344
|
+
const ref = args[0];
|
|
345
|
+
const text = mcpPromptText(ref, flags);
|
|
346
|
+
if (flags.json) ui.json({ prompt: text });
|
|
347
|
+
else console.log(`\n${text}\n`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function work(args, flags) {
|
|
351
|
+
requireAuth();
|
|
352
|
+
await list(args, { ...flags, limit: flags.limit || 10 });
|
|
353
|
+
console.log('');
|
|
354
|
+
ui.subheading('MCP prompt');
|
|
355
|
+
console.log(mcpPromptText(args[0], flags));
|
|
356
|
+
console.log('');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
list,
|
|
361
|
+
show,
|
|
362
|
+
block,
|
|
363
|
+
fp,
|
|
364
|
+
prompt,
|
|
365
|
+
work,
|
|
366
|
+
};
|
package/cli/init.js
CHANGED
|
@@ -86,7 +86,7 @@ async function init(_args, flags) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function initCredentials(flags) {
|
|
89
|
-
config.ensureCredentialDefaults({ local: true });
|
|
89
|
+
config.ensureCredentialDefaults({ local: true, enableFirewall: true });
|
|
90
90
|
config.ensureLocalGitignore();
|
|
91
91
|
const creds = config.loadCredentials();
|
|
92
92
|
creds.config = creds.config || {};
|
package/cli.js
CHANGED
|
@@ -151,20 +151,48 @@ const COMMANDS = {
|
|
|
151
151
|
},
|
|
152
152
|
defaultSub: 'list',
|
|
153
153
|
},
|
|
154
|
-
notifications: {
|
|
155
|
-
desc: 'Manage notifications',
|
|
156
|
-
usage: 'securenow notifications <subcommand> [options]',
|
|
154
|
+
notifications: {
|
|
155
|
+
desc: 'Manage notifications',
|
|
156
|
+
usage: 'securenow notifications <subcommand> [options]',
|
|
157
157
|
sub: {
|
|
158
158
|
list: { desc: 'List notifications', flags: { limit: 'Max results', page: 'Page number' }, run: (a, f) => require('./cli/monitor').notificationsList(a, f) },
|
|
159
159
|
read: { desc: 'Mark notification as read', usage: 'securenow notifications read <id>', run: (a, f) => require('./cli/monitor').notificationsRead(a, f) },
|
|
160
160
|
'read-all': { desc: 'Mark all as read', run: () => require('./cli/monitor').notificationsReadAll() },
|
|
161
161
|
unread: { desc: 'Show unread count', run: () => require('./cli/monitor').notificationsUnread() },
|
|
162
|
-
},
|
|
163
|
-
defaultSub: 'list',
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
desc: '
|
|
167
|
-
usage: 'securenow
|
|
162
|
+
},
|
|
163
|
+
defaultSub: 'list',
|
|
164
|
+
},
|
|
165
|
+
human: {
|
|
166
|
+
desc: 'Work the human action queue prepared by SecureNow AI',
|
|
167
|
+
usage: 'securenow human <list|show|block|fp|prompt|work> [row|notificationId:ip] [options]',
|
|
168
|
+
flags: {
|
|
169
|
+
json: 'Output as JSON',
|
|
170
|
+
page: 'Queue page number',
|
|
171
|
+
limit: 'Queue page size',
|
|
172
|
+
search: 'Search IP, rule, path, or verdict',
|
|
173
|
+
reason: 'Reason for block/false-positive decisions',
|
|
174
|
+
yes: 'Confirm write actions without prompting',
|
|
175
|
+
force: 'Alias for --yes',
|
|
176
|
+
conditions: 'False-positive conditions JSON array',
|
|
177
|
+
'match-mode': 'False-positive match mode: all or any',
|
|
178
|
+
'rule-scope': 'False-positive scope: this_rule | specific_rules | all_existing | any_rule',
|
|
179
|
+
'create-exclusion': 'Create a restrictive exclusion when marking false positive',
|
|
180
|
+
'apply-existing': 'Apply false-positive decision to existing matching rows',
|
|
181
|
+
mode: 'Prompt mode label for MCP prompt output',
|
|
182
|
+
},
|
|
183
|
+
sub: {
|
|
184
|
+
list: { desc: 'List human decisions AI prepared', run: (a, f) => require('./cli/human').list(a, f) },
|
|
185
|
+
show: { desc: 'Show one row with AI report, proofs, DAG, and trace links', usage: 'securenow human show <row|notificationId:ip>', run: (a, f) => require('./cli/human').show(a, f) },
|
|
186
|
+
block: { desc: 'Approve the AI block recommendation for a row', usage: 'securenow human block <row|notificationId:ip> --yes --reason "..."', run: (a, f) => require('./cli/human').block(a, f) },
|
|
187
|
+
fp: { desc: 'Mark a row as a scoped false positive', usage: 'securenow human fp <row|notificationId:ip> --yes --reason "..."', run: (a, f) => require('./cli/human').fp(a, f) },
|
|
188
|
+
prompt: { desc: 'Print a Codex/Claude MCP prompt for row or queue work', usage: 'securenow human prompt [row|notificationId:ip] [--limit 10]', run: (a, f) => require('./cli/human').prompt(a, f) },
|
|
189
|
+
work: { desc: 'List the queue and print the MCP runbook to work it deeply', usage: 'securenow human work [--limit 10]', run: (a, f) => require('./cli/human').work(a, f) },
|
|
190
|
+
},
|
|
191
|
+
defaultSub: 'list',
|
|
192
|
+
},
|
|
193
|
+
alerts: {
|
|
194
|
+
desc: 'Manage alerting',
|
|
195
|
+
usage: 'securenow alerts <subcommand> [options]',
|
|
168
196
|
sub: {
|
|
169
197
|
rules: {
|
|
170
198
|
desc: 'List, show, or update alert rules',
|
|
@@ -469,7 +497,7 @@ function showHelp(commandName) {
|
|
|
469
497
|
'Authentication': ['login', 'logout', 'whoami', 'credentials'],
|
|
470
498
|
'Applications': ['apps', 'init', 'status'],
|
|
471
499
|
'Observe': ['traces', 'logs', 'analytics'],
|
|
472
|
-
'Detect & Respond': ['notifications', 'alerts', 'fp'],
|
|
500
|
+
'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
|
|
473
501
|
'Investigate': ['ip', 'forensics'],
|
|
474
502
|
'Firewall': ['firewall'],
|
|
475
503
|
'Remediation': ['blocklist', 'allowlist', 'trusted'],
|
|
@@ -130,7 +130,7 @@ Legacy environment variables are fallback-only for existing deployments. New loc
|
|
|
130
130
|
| `config.runtime.strict` | `false` | Exit clustered workers when no app identity resolves. |
|
|
131
131
|
| `config.runtime.testSpan` | `false` | Prefer `npx securenow test-span` for manual checks. |
|
|
132
132
|
| `config.runtime.hideBanner` | `false` | Hide free-trial response banner. |
|
|
133
|
-
| `config.firewall.enabled` | `true` | Local
|
|
133
|
+
| `config.firewall.enabled` | `true` | Local SDK firewall switch. Leave absent/true for protection; set false only when intentionally disabling in this credentials file. Dashboard app toggle also applies. |
|
|
134
134
|
| `config.firewall.apiUrl` | `https://api.securenow.ai` | SecureNow API base URL. |
|
|
135
135
|
| `config.firewall.versionCheckInterval` | `10` | Seconds between lightweight version checks. |
|
|
136
136
|
| `config.firewall.syncInterval` | `300` | Seconds between full blocklist syncs. |
|
package/docs/FIREWALL-GUIDE.md
CHANGED
|
@@ -199,13 +199,12 @@ SECURENOW_FIREWALL_CLOUD_DRY_RUN=1
|
|
|
199
199
|
|
|
200
200
|
## Environment Variables Reference
|
|
201
201
|
|
|
202
|
-
| Variable | Default | Description |
|
|
203
|
-
|----------|---------|-------------|
|
|
204
|
-
| `SECURENOW_API_KEY` | *(from creds file)* | API key with `firewall:read` scope. Since v7.1.0 the firewall also reads this from `.securenow/credentials.json` (written by `securenow login` or `securenow api-key set`), so this env var is optional. The env var only wins if it starts with `snk_live_`; otherwise the credentials file is used. |
|
|
205
|
-
| `SECURENOW_API_URL` | `https://api.securenow.ai` | API base URL. Auto-fallback to `http://localhost:4000` on ECONNREFUSED. |
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
208
|
-
| `SECURENOW_FIREWALL_SYNC_INTERVAL` | `300` | Full blocklist refresh interval in seconds (safety net) |
|
|
202
|
+
| Variable | Default | Description |
|
|
203
|
+
|----------|---------|-------------|
|
|
204
|
+
| `SECURENOW_API_KEY` | *(from creds file)* | API key with `firewall:read` scope. Since v7.1.0 the firewall also reads this from `.securenow/credentials.json` (written by `securenow login` or `securenow api-key set`), so this env var is optional. The env var only wins if it starts with `snk_live_`; otherwise the credentials file is used. |
|
|
205
|
+
| `SECURENOW_API_URL` | `https://api.securenow.ai` | API base URL. Auto-fallback to `http://localhost:4000` on ECONNREFUSED. |
|
|
206
|
+
| `SECURENOW_FIREWALL_VERSION_INTERVAL` | `10` | Seconds between version checks (lightweight ETag-based) |
|
|
207
|
+
| `SECURENOW_FIREWALL_SYNC_INTERVAL` | `300` | Full blocklist refresh interval in seconds (safety net) |
|
|
209
208
|
| `SECURENOW_FIREWALL_FAIL_MODE` | `open` | `open` = allow when list unavailable; `closed` = block all |
|
|
210
209
|
| `SECURENOW_FIREWALL_STATUS_CODE` | `403` | HTTP status code for blocked requests (Layer 1) |
|
|
211
210
|
| `SECURENOW_FIREWALL_LOG` | `1` | Log blocked requests to console (`0` to silence) |
|
|
@@ -390,14 +389,16 @@ All layers clean up on process exit (SIGINT/SIGTERM):
|
|
|
390
389
|
echo $SECURENOW_API_KEY
|
|
391
390
|
```
|
|
392
391
|
|
|
393
|
-
**Check 2:** Is the firewall disabled?
|
|
394
|
-
|
|
395
|
-
```bash
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
|
|
392
|
+
**Check 2:** Is the firewall disabled in SecureNow config or dashboard?
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
npx securenow env
|
|
396
|
+
npx securenow firewall apps
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The local SDK switch is `config.firewall.enabled` in `.securenow/credentials.json`. The runtime dashboard toggle is per app/environment.
|
|
400
|
+
|
|
401
|
+
**Check 3:** Check the startup log for sync errors:
|
|
401
402
|
|
|
402
403
|
```
|
|
403
404
|
[securenow] Firewall: initial sync failed: API returned 401
|
package/firewall.js
CHANGED
|
@@ -15,9 +15,11 @@ let _lastSyncEtag = null;
|
|
|
15
15
|
let _initialized = false;
|
|
16
16
|
let _consecutiveErrors = 0;
|
|
17
17
|
let _layers = [];
|
|
18
|
-
let _rawIps = [];
|
|
19
|
-
let _stats = { syncs: 0, blocked: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
20
|
-
let _localhostFallbackTried = false;
|
|
18
|
+
let _rawIps = [];
|
|
19
|
+
let _stats = { syncs: 0, blocked: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
20
|
+
let _localhostFallbackTried = false;
|
|
21
|
+
let _eventQueue = [];
|
|
22
|
+
let _eventTimer = null;
|
|
21
23
|
|
|
22
24
|
// Remote toggle — set by /firewall/sync when an appKey is in scope. Default
|
|
23
25
|
// true so a missing/unreachable backend fails open (matches pre-7.3 behavior).
|
|
@@ -45,7 +47,10 @@ let _retryAfterUntil = 0;
|
|
|
45
47
|
|
|
46
48
|
// Keep-alive agents — reuse TCP connections across polls (TLS handshake once)
|
|
47
49
|
const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
|
|
48
|
-
const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
|
|
50
|
+
const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
|
|
51
|
+
const EVENT_FLUSH_INTERVAL_MS = 2_000;
|
|
52
|
+
const EVENT_BATCH_SIZE = 25;
|
|
53
|
+
const EVENT_QUEUE_MAX = 1_000;
|
|
49
54
|
|
|
50
55
|
// Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
|
|
51
56
|
let _useUnifiedSync = true;
|
|
@@ -118,7 +123,7 @@ function agentFor(url) {
|
|
|
118
123
|
return url.startsWith('https') ? _httpsAgent : _httpAgent;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
function httpGet(url, extraHeaders, timeout, callback) {
|
|
126
|
+
function httpGet(url, extraHeaders, timeout, callback) {
|
|
122
127
|
const mod = url.startsWith('https') ? https : http;
|
|
123
128
|
const parsed = new URL(url);
|
|
124
129
|
|
|
@@ -142,8 +147,89 @@ function httpGet(url, extraHeaders, timeout, callback) {
|
|
|
142
147
|
|
|
143
148
|
req.on('error', (err) => callback(err));
|
|
144
149
|
req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
|
|
145
|
-
req.end();
|
|
146
|
-
}
|
|
150
|
+
req.end();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function httpPostJson(url, body, timeout, callback) {
|
|
154
|
+
const mod = url.startsWith('https') ? https : http;
|
|
155
|
+
const parsed = new URL(url);
|
|
156
|
+
const payload = JSON.stringify(body || {});
|
|
157
|
+
|
|
158
|
+
const req = mod.request({
|
|
159
|
+
hostname: parsed.hostname,
|
|
160
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
161
|
+
path: parsed.pathname + parsed.search,
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Authorization': `Bearer ${_options.apiKey}`,
|
|
165
|
+
'User-Agent': 'securenow-firewall-sdk',
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
168
|
+
},
|
|
169
|
+
timeout,
|
|
170
|
+
agent: agentFor(url),
|
|
171
|
+
}, (res) => {
|
|
172
|
+
let data = '';
|
|
173
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
174
|
+
res.on('end', () => { callback(null, res, data); });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
req.on('error', (err) => callback(err));
|
|
178
|
+
req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
|
|
179
|
+
req.write(payload);
|
|
180
|
+
req.end();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function scheduleEventFlush() {
|
|
184
|
+
if (_eventTimer || _eventQueue.length === 0) return;
|
|
185
|
+
_eventTimer = setTimeout(() => {
|
|
186
|
+
_eventTimer = null;
|
|
187
|
+
flushFirewallEvents();
|
|
188
|
+
}, EVENT_FLUSH_INTERVAL_MS);
|
|
189
|
+
if (_eventTimer.unref) _eventTimer.unref();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function flushFirewallEvents() {
|
|
193
|
+
if (!_options || !_options.apiKey || !_options.apiUrl || _eventQueue.length === 0) return;
|
|
194
|
+
if (shouldSkipRequest()) {
|
|
195
|
+
_eventQueue = [];
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const batch = _eventQueue.splice(0, EVENT_BATCH_SIZE);
|
|
200
|
+
const url = buildFirewallUrl('/firewall/events');
|
|
201
|
+
|
|
202
|
+
httpPostJson(url, { events: batch }, 5000, (err, res) => {
|
|
203
|
+
if (err || !res || res.statusCode >= 400) {
|
|
204
|
+
if (_options.log) {
|
|
205
|
+
const msg = err ? err.message : `API returned ${res.statusCode}`;
|
|
206
|
+
console.warn('[securenow] Firewall: failed to report blocked-request ledger:', msg);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (_eventQueue.length) scheduleEventFlush();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function reportFirewallEvent(event) {
|
|
214
|
+
if (!_options || !_options.apiKey || !_options.apiUrl) return;
|
|
215
|
+
|
|
216
|
+
const item = {
|
|
217
|
+
applicationKey: _options.appKey || null,
|
|
218
|
+
environment: _options.environment || 'production',
|
|
219
|
+
action: 'blocked',
|
|
220
|
+
statusCode: (_options && _options.statusCode) || 403,
|
|
221
|
+
occurredAt: new Date().toISOString(),
|
|
222
|
+
...event,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
_eventQueue.push(item);
|
|
226
|
+
if (_eventQueue.length > EVENT_QUEUE_MAX) {
|
|
227
|
+
_eventQueue.splice(0, _eventQueue.length - EVENT_QUEUE_MAX);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (_eventQueue.length >= EVENT_BATCH_SIZE) flushFirewallEvents();
|
|
231
|
+
else scheduleEventFlush();
|
|
232
|
+
}
|
|
147
233
|
|
|
148
234
|
// ────── Unified Sync (v2 — single request for everything) ──────
|
|
149
235
|
|
|
@@ -609,23 +695,39 @@ function firewallRequestHandler(req, res) {
|
|
|
609
695
|
const ip = resolveClientIp(req);
|
|
610
696
|
|
|
611
697
|
// Allowlist check: if active, only listed IPs are allowed through
|
|
612
|
-
if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
|
|
613
|
-
if (!_allowlistMatcher.isBlocked(ip)) {
|
|
614
|
-
_stats.blocked++;
|
|
615
|
-
if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
698
|
+
if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
|
|
699
|
+
if (!_allowlistMatcher.isBlocked(ip)) {
|
|
700
|
+
_stats.blocked++;
|
|
701
|
+
if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
|
|
702
|
+
reportFirewallEvent({
|
|
703
|
+
source: 'allowlist',
|
|
704
|
+
ip,
|
|
705
|
+
matchedEntry: '',
|
|
706
|
+
method: req.method || '',
|
|
707
|
+
path: req.url || '',
|
|
708
|
+
userAgent: req.headers['user-agent'] || '',
|
|
709
|
+
});
|
|
710
|
+
sendBlockResponse(req, res, ip);
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
621
715
|
|
|
622
716
|
// Blocklist check
|
|
623
|
-
if (_matcher && _matcher.isBlocked(ip)) {
|
|
624
|
-
_stats.blocked++;
|
|
625
|
-
if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP', ip);
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
717
|
+
if (_matcher && _matcher.isBlocked(ip)) {
|
|
718
|
+
_stats.blocked++;
|
|
719
|
+
if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP', ip);
|
|
720
|
+
reportFirewallEvent({
|
|
721
|
+
source: 'blocklist',
|
|
722
|
+
ip,
|
|
723
|
+
matchedEntry: ip,
|
|
724
|
+
method: req.method || '',
|
|
725
|
+
path: req.url || '',
|
|
726
|
+
userAgent: req.headers['user-agent'] || '',
|
|
727
|
+
});
|
|
728
|
+
sendBlockResponse(req, res, ip);
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
629
731
|
|
|
630
732
|
return false;
|
|
631
733
|
}
|
|
@@ -721,11 +823,13 @@ function init(options) {
|
|
|
721
823
|
startSyncLoop();
|
|
722
824
|
}
|
|
723
825
|
|
|
724
|
-
function shutdown() {
|
|
725
|
-
if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
|
|
726
|
-
if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
|
|
727
|
-
|
|
728
|
-
|
|
826
|
+
function shutdown() {
|
|
827
|
+
if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
|
|
828
|
+
if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
|
|
829
|
+
if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
|
|
830
|
+
flushFirewallEvents();
|
|
831
|
+
|
|
832
|
+
_circuitState = 'closed';
|
|
729
833
|
_circuitOpenedAt = 0;
|
|
730
834
|
_consecutiveErrors = 0;
|
|
731
835
|
_pollInflight = false;
|
package/mcp/catalog.js
CHANGED
|
@@ -408,6 +408,94 @@ const TOOLS = [
|
|
|
408
408
|
...confirmSchema,
|
|
409
409
|
}, ['id', 'confirm', 'reason']),
|
|
410
410
|
},
|
|
411
|
+
{
|
|
412
|
+
name: 'securenow_notifications_get',
|
|
413
|
+
title: 'Get Notification',
|
|
414
|
+
description: 'Get one notification/case with alert context, clusters, and IP investigations.',
|
|
415
|
+
scope: 'notifications:read',
|
|
416
|
+
readOnly: true,
|
|
417
|
+
method: 'GET',
|
|
418
|
+
endpoint: '/notifications/:id',
|
|
419
|
+
pathParams: ['id'],
|
|
420
|
+
inputSchema: objectSchema({
|
|
421
|
+
id: string('Notification id.'),
|
|
422
|
+
}, ['id']),
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: 'securenow_human_actions_list',
|
|
426
|
+
title: 'List Human Action Queue',
|
|
427
|
+
description: 'List approval-gated human decisions prepared by SecureNow AI, ordered by urgency.',
|
|
428
|
+
scope: 'notifications:read',
|
|
429
|
+
readOnly: true,
|
|
430
|
+
method: 'GET',
|
|
431
|
+
endpoint: '/notifications/approval-tasks',
|
|
432
|
+
queryFields: ['limit', 'page', 'search'],
|
|
433
|
+
inputSchema: objectSchema({
|
|
434
|
+
...pagingInput,
|
|
435
|
+
search: string('Optional search over IP, rule, path, or verdict.'),
|
|
436
|
+
}),
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: 'securenow_human_action_report',
|
|
440
|
+
title: 'Get Human Action Report',
|
|
441
|
+
description: 'Fetch the full IP investigation report, DAG steps, proofs, metadata, and AI decision for one human action row.',
|
|
442
|
+
scope: 'notifications:read',
|
|
443
|
+
readOnly: true,
|
|
444
|
+
method: 'GET',
|
|
445
|
+
endpoint: '/notifications/:notificationId/ips/:ip',
|
|
446
|
+
pathParams: ['notificationId', 'ip'],
|
|
447
|
+
inputSchema: objectSchema({
|
|
448
|
+
notificationId: string('Notification id from the human action row.'),
|
|
449
|
+
ip: string('IP address from the human action row.'),
|
|
450
|
+
}, ['notificationId', 'ip']),
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: 'securenow_human_action_block',
|
|
454
|
+
title: 'Approve AI Block Recommendation',
|
|
455
|
+
description: 'Approve the AI-prepared block decision for an IP. Write action; requires confirmation.',
|
|
456
|
+
scope: 'notifications:write',
|
|
457
|
+
readOnly: false,
|
|
458
|
+
destructive: true,
|
|
459
|
+
confirm: true,
|
|
460
|
+
method: 'PUT',
|
|
461
|
+
endpoint: '/notifications/:notificationId/ips/:ip/status',
|
|
462
|
+
pathParams: ['notificationId', 'ip'],
|
|
463
|
+
fixedBody: { status: 'blocked', decisionSource: 'ai_dag' },
|
|
464
|
+
reasonAsNote: true,
|
|
465
|
+
bodyFields: ['note', 'verdict', 'riskScore'],
|
|
466
|
+
inputSchema: objectSchema({
|
|
467
|
+
notificationId: string('Notification id from the human action row.'),
|
|
468
|
+
ip: string('IP address to block.'),
|
|
469
|
+
note: string('Audit note explaining why the AI block was approved.'),
|
|
470
|
+
verdict: string('Optional final verdict text.'),
|
|
471
|
+
riskScore: number('Optional risk score.', { minimum: 0, maximum: 100 }),
|
|
472
|
+
...confirmSchema,
|
|
473
|
+
}, ['notificationId', 'ip', 'confirm', 'reason']),
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: 'securenow_human_action_false_positive',
|
|
477
|
+
title: 'Mark Human Action False Positive',
|
|
478
|
+
description: 'Mark an AI-prepared human action as a scoped false positive. Write action; requires confirmation.',
|
|
479
|
+
scope: 'notifications:write',
|
|
480
|
+
readOnly: false,
|
|
481
|
+
confirm: true,
|
|
482
|
+
method: 'POST',
|
|
483
|
+
endpoint: '/notifications/:notificationId/ips/:ip/false-positive',
|
|
484
|
+
pathParams: ['notificationId', 'ip'],
|
|
485
|
+
bodyFields: ['reason', 'conditions', 'matchMode', 'createExclusion', 'applyToExisting', 'ruleScope', 'targetRuleIds', 'aiConfidence'],
|
|
486
|
+
inputSchema: objectSchema({
|
|
487
|
+
notificationId: string('Notification id from the human action row.'),
|
|
488
|
+
ip: string('IP address to mark as false positive.'),
|
|
489
|
+
conditions: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Restrictive false-positive conditions. Prefer app/rule/path/method/status/user-agent/body scope.' },
|
|
490
|
+
matchMode: string('Condition match mode: all or any.'),
|
|
491
|
+
createExclusion: boolean('Whether to create a restrictive exclusion rule.'),
|
|
492
|
+
applyToExisting: boolean('Whether to apply to existing matching IP investigations.'),
|
|
493
|
+
ruleScope: string('Scope: this_rule, specific_rules, all_existing, or any_rule.'),
|
|
494
|
+
targetRuleIds: arrayOfStrings('Specific rule ids when ruleScope is specific_rules.'),
|
|
495
|
+
aiConfidence: number('AI confidence for audit metadata.', { minimum: 0, maximum: 100 }),
|
|
496
|
+
...confirmSchema,
|
|
497
|
+
}, ['notificationId', 'ip', 'confirm', 'reason']),
|
|
498
|
+
},
|
|
411
499
|
{
|
|
412
500
|
name: 'securenow_ip_lookup',
|
|
413
501
|
title: 'IP Intelligence Lookup',
|
|
@@ -700,6 +788,27 @@ const PROMPTS = [
|
|
|
700
788
|
{ name: 'environment', description: 'Environment to investigate. Defaults to production; use all only when explicitly needed.', required: false },
|
|
701
789
|
],
|
|
702
790
|
},
|
|
791
|
+
{
|
|
792
|
+
name: 'investigate_human_action_row',
|
|
793
|
+
title: 'Investigate Human Action Row',
|
|
794
|
+
description: 'Use MCP tools to deeply review one Requires Human row and either block the IP or mark a scoped false positive.',
|
|
795
|
+
arguments: [
|
|
796
|
+
{ name: 'rowNumber', description: '1-based row number from the Requires Human queue.', required: true },
|
|
797
|
+
{ name: 'page', description: 'Queue page number. Defaults to 1.', required: false },
|
|
798
|
+
{ name: 'limit', description: 'Rows to fetch. Defaults to 20.', required: false },
|
|
799
|
+
{ name: 'confirmWrites', description: 'Set true only when the user explicitly wants the MCP agent to execute decisions.', required: false },
|
|
800
|
+
],
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: 'work_human_actions',
|
|
804
|
+
title: 'Work Human Actions',
|
|
805
|
+
description: 'Use MCP tools to work the approval queue from most urgent to least urgent, with trace verification and scoped decisions.',
|
|
806
|
+
arguments: [
|
|
807
|
+
{ name: 'limit', description: 'Maximum rows to review this run. Defaults to 10.', required: false },
|
|
808
|
+
{ name: 'search', description: 'Optional search filter for the queue.', required: false },
|
|
809
|
+
{ name: 'confirmWrites', description: 'Set true only when the user explicitly wants the MCP agent to execute decisions.', required: false },
|
|
810
|
+
],
|
|
811
|
+
},
|
|
703
812
|
];
|
|
704
813
|
|
|
705
814
|
function promptMessages(name, args = {}) {
|
|
@@ -759,6 +868,56 @@ function promptMessages(name, args = {}) {
|
|
|
759
868
|
];
|
|
760
869
|
}
|
|
761
870
|
|
|
871
|
+
if (name === 'investigate_human_action_row') {
|
|
872
|
+
const rowNumber = args.rowNumber || '<rowNumber>';
|
|
873
|
+
const page = args.page || 1;
|
|
874
|
+
const limit = args.limit || 20;
|
|
875
|
+
const confirmWrites = args.confirmWrites === true || args.confirmWrites === 'true';
|
|
876
|
+
return [
|
|
877
|
+
{
|
|
878
|
+
role: 'user',
|
|
879
|
+
content: {
|
|
880
|
+
type: 'text',
|
|
881
|
+
text: [
|
|
882
|
+
`Investigate Requires Human row ${rowNumber} using SecureNow MCP.`,
|
|
883
|
+
`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.`,
|
|
884
|
+
'Read the AI report, finalDecision, DAG steps, findings, proofs, metadata paths/user agents/status codes, and trace IDs.',
|
|
885
|
+
'Open trace evidence with securenow_traces_show and correlated logs with securenow_logs_for_trace when trace IDs are available.',
|
|
886
|
+
'Return one of only two outcomes: Block IP or False Positive. If evidence is ambiguous, stop and explain what is missing.',
|
|
887
|
+
confirmWrites
|
|
888
|
+
? 'The user requested execution. If evidence supports the decision, call securenow_human_action_block or securenow_human_action_false_positive with confirm:true and a precise reason.'
|
|
889
|
+
: 'Do not execute write tools yet. Prepare the recommended decision and exact tool call the user can approve.',
|
|
890
|
+
'False positives must be narrow: app + alert rule + path + method/status/user-agent/body evidence where possible. Never globally trust an IP by default.',
|
|
891
|
+
].join('\n'),
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
];
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (name === 'work_human_actions') {
|
|
898
|
+
const limit = args.limit || 10;
|
|
899
|
+
const confirmWrites = args.confirmWrites === true || args.confirmWrites === 'true';
|
|
900
|
+
return [
|
|
901
|
+
{
|
|
902
|
+
role: 'user',
|
|
903
|
+
content: {
|
|
904
|
+
type: 'text',
|
|
905
|
+
text: [
|
|
906
|
+
'Work my SecureNow Requires Human queue like a senior security analyst using the MCP tools.',
|
|
907
|
+
`Review up to ${limit} row(s), most urgent first.${args.search ? ` Search filter: ${args.search}.` : ''}`,
|
|
908
|
+
'Start with securenow_human_actions_list. For each row, call securenow_notifications_get and securenow_human_action_report, inspect the AI report/DAG/proofs/trace IDs, and fetch trace/log evidence where useful.',
|
|
909
|
+
'For each row choose exactly one outcome: Block IP, False Positive, or Skip because evidence is insufficient. Explain skipped rows.',
|
|
910
|
+
confirmWrites
|
|
911
|
+
? 'The user requested execution. For supported decisions, call the correct write tool with confirm:true and a precise reason, then continue.'
|
|
912
|
+
: 'Do not execute write tools yet. Produce a row-by-row action plan and exact MCP write calls for user approval.',
|
|
913
|
+
'For block decisions, use securenow_human_action_block. For false positives, use securenow_human_action_false_positive with restrictive conditions. Avoid broad/global trust.',
|
|
914
|
+
'End with counts: handled, proposed block, proposed false positive, skipped, still waiting.',
|
|
915
|
+
].join('\n'),
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
];
|
|
919
|
+
}
|
|
920
|
+
|
|
762
921
|
throw new Error(`Unknown prompt: ${name}`);
|
|
763
922
|
}
|
|
764
923
|
|
|
@@ -829,6 +988,10 @@ function buildApiRequest(tool, rawArgs = {}) {
|
|
|
829
988
|
if (value != null && value !== '') body[key] = value;
|
|
830
989
|
}
|
|
831
990
|
|
|
991
|
+
if (tool.reasonAsNote && !body.note && args.reason) {
|
|
992
|
+
body.note = args.reason;
|
|
993
|
+
}
|
|
994
|
+
|
|
832
995
|
return {
|
|
833
996
|
method: tool.method,
|
|
834
997
|
endpoint,
|
package/package.json
CHANGED