securenow 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CONSUMING-APPS-GUIDE.md +455 -0
  2. package/NPM_README.md +2029 -0
  3. package/README.md +297 -40
  4. package/SKILL-API.md +634 -0
  5. package/SKILL-CLI.md +454 -0
  6. package/cidr.js +83 -0
  7. package/cli/apps.js +585 -0
  8. package/cli/auth.js +280 -0
  9. package/cli/client.js +115 -0
  10. package/cli/config.js +173 -0
  11. package/cli/diagnostics.js +387 -0
  12. package/cli/firewall.js +100 -0
  13. package/cli/fp.js +638 -0
  14. package/cli/init.js +201 -0
  15. package/cli/monitor.js +440 -0
  16. package/cli/run.js +148 -0
  17. package/cli/security.js +980 -0
  18. package/cli/ui.js +386 -0
  19. package/cli/utils.js +127 -0
  20. package/cli.js +466 -455
  21. package/console-instrumentation.js +147 -136
  22. package/docs/ALL-FRAMEWORKS-QUICKSTART.md +1377 -455
  23. package/docs/API-KEYS-GUIDE.md +233 -0
  24. package/docs/ARCHITECTURE.md +3 -3
  25. package/docs/AUTO-BODY-CAPTURE.md +1 -1
  26. package/docs/AUTO-SETUP-SUMMARY.md +331 -0
  27. package/docs/AUTO-SETUP.md +4 -4
  28. package/docs/AUTOMATIC-IP-CAPTURE.md +5 -5
  29. package/docs/BODY-CAPTURE-FIX.md +261 -0
  30. package/docs/BODY-CAPTURE-QUICKSTART.md +2 -2
  31. package/docs/CHANGELOG-NEXTJS.md +1 -35
  32. package/docs/COMPLETION-REPORT.md +408 -0
  33. package/docs/CUSTOMER-GUIDE.md +16 -16
  34. package/docs/EASIEST-SETUP.md +5 -5
  35. package/docs/ENVIRONMENT-VARIABLES.md +880 -652
  36. package/docs/EXPRESS-BODY-CAPTURE.md +13 -12
  37. package/docs/EXPRESS-SETUP-GUIDE.md +719 -720
  38. package/docs/FINAL-SOLUTION.md +335 -0
  39. package/docs/FIREWALL-GUIDE.md +426 -0
  40. package/docs/IMPLEMENTATION-SUMMARY.md +410 -0
  41. package/docs/INDEX.md +22 -4
  42. package/docs/LOGGING-GUIDE.md +701 -708
  43. package/docs/LOGGING-QUICKSTART.md +234 -255
  44. package/docs/NEXTJS-BODY-CAPTURE-COMPARISON.md +323 -0
  45. package/docs/NEXTJS-BODY-CAPTURE.md +2 -2
  46. package/docs/NEXTJS-GUIDE.md +14 -14
  47. package/docs/NEXTJS-QUICKSTART.md +1 -1
  48. package/docs/NEXTJS-SETUP-COMPLETE.md +795 -0
  49. package/docs/NEXTJS-WRAPPER-APPROACH.md +1 -1
  50. package/docs/NUXT-GUIDE.md +166 -0
  51. package/docs/QUICKSTART-BODY-CAPTURE.md +2 -2
  52. package/docs/REDACTION-EXAMPLES.md +1 -1
  53. package/docs/REQUEST-BODY-CAPTURE.md +19 -10
  54. package/docs/SOLUTION-SUMMARY.md +312 -0
  55. package/docs/VERCEL-OTEL-MIGRATION.md +3 -3
  56. package/examples/README.md +6 -6
  57. package/examples/instrumentation-with-auto-capture.ts +1 -1
  58. package/examples/nextjs-env-example.txt +2 -2
  59. package/examples/nextjs-instrumentation.js +1 -1
  60. package/examples/nextjs-instrumentation.ts +1 -1
  61. package/examples/nextjs-with-logging-example.md +6 -6
  62. package/examples/nextjs-with-options.ts +1 -1
  63. package/examples/test-nextjs-setup.js +1 -1
  64. package/firewall-cloud.js +212 -0
  65. package/firewall-iptables.js +139 -0
  66. package/firewall-only.js +38 -0
  67. package/firewall-tcp.js +74 -0
  68. package/firewall.js +720 -0
  69. package/free-trial-banner.js +174 -0
  70. package/nextjs-auto-capture.js +199 -207
  71. package/nextjs-middleware.js +186 -181
  72. package/nextjs-webpack-config.js +88 -53
  73. package/nextjs-wrapper.js +158 -158
  74. package/nextjs.d.ts +1 -1
  75. package/nextjs.js +639 -647
  76. package/nuxt-server-plugin.mjs +423 -0
  77. package/nuxt.d.ts +60 -0
  78. package/nuxt.mjs +75 -0
  79. package/package.json +186 -164
  80. package/postinstall.js +6 -6
  81. package/register.d.ts +1 -1
  82. package/register.js +39 -4
  83. package/resolve-ip.js +77 -0
  84. package/tracing.d.ts +2 -1
  85. package/tracing.js +295 -34
  86. package/web-vite.mjs +239 -156
  87. package/LICENSE +0 -15
package/cli/fp.js ADDED
@@ -0,0 +1,638 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { api, requireAuth } = require('./client');
6
+ const ui = require('./ui');
7
+
8
+ // ── Helpers ──
9
+
10
+ function parseConditions(flags) {
11
+ if (!flags.conditions) return null;
12
+ try {
13
+ const parsed = JSON.parse(flags.conditions);
14
+ if (!Array.isArray(parsed)) throw new Error();
15
+ return parsed;
16
+ } catch {
17
+ ui.error('--conditions must be a valid JSON array, e.g. \'[{"field":"path","operator":"equals","value":"/api/event"}]\'');
18
+ process.exit(1);
19
+ }
20
+ }
21
+
22
+ function readBody(args, flags) {
23
+ const raw = args[0] || flags.body;
24
+ if (!raw && !process.stdin.isTTY) {
25
+ try {
26
+ return fs.readFileSync(0, 'utf8');
27
+ } catch { return null; }
28
+ }
29
+ if (!raw) return null;
30
+ if (raw.startsWith('@')) {
31
+ const filePath = path.resolve(raw.slice(1));
32
+ if (!fs.existsSync(filePath)) {
33
+ ui.error(`File not found: ${filePath}`);
34
+ process.exit(1);
35
+ }
36
+ return fs.readFileSync(filePath, 'utf8');
37
+ }
38
+ return raw;
39
+ }
40
+
41
+ function conditionsSummary(conditions, matchMode) {
42
+ if (!conditions || !conditions.length) return ui.c.dim('(none)');
43
+ const mode = matchMode === 'any' ? ' OR ' : ' AND ';
44
+ return conditions.map(c => `${c.field} ${c.operator} "${ui.truncate(c.value || '', 30)}"`).join(mode);
45
+ }
46
+
47
+ function printConditions(conditions, matchMode) {
48
+ if (!conditions?.length) { console.log(ui.c.dim(' No conditions')); return; }
49
+ const modeLabel = matchMode === 'any' ? ui.c.yellow('ANY (OR)') : ui.c.cyan('ALL (AND)');
50
+ console.log(` Match mode: ${modeLabel}\n`);
51
+ conditions.forEach((c, i) => {
52
+ const prefix = i > 0 ? ` ${matchMode === 'any' ? 'OR ' : 'AND'}` : ' ';
53
+ console.log(`${prefix} ${ui.c.bold(c.field)} ${ui.c.cyan(c.operator)} ${ui.c.dim('"')}${c.value || ''}${ui.c.dim('"')}`);
54
+ });
55
+ }
56
+
57
+ // ── List ──
58
+
59
+ async function list(args, flags) {
60
+ requireAuth();
61
+ const s = ui.spinner('Fetching exclusion rules');
62
+ try {
63
+ const data = await api.get('/false-positives');
64
+ const exclusions = data.exclusions || [];
65
+ s.stop(`Found ${exclusions.length} exclusion rule${exclusions.length !== 1 ? 's' : ''}`);
66
+
67
+ if (flags.json) { ui.json(data); return; }
68
+
69
+ console.log('');
70
+ if (!exclusions.length) {
71
+ ui.info('No exclusion rules found. Create one with: securenow fp create');
72
+ console.log('');
73
+ return;
74
+ }
75
+
76
+ const rows = exclusions.map(e => {
77
+ const scope = e.type === 'global' ? ui.c.cyan('All Rules') : `Rule: ${ui.truncate(e.ruleName || '?', 18)}`;
78
+ return [
79
+ ui.c.dim(ui.truncate(e._id, 12)),
80
+ scope,
81
+ ui.truncate(conditionsSummary(e.conditions, e.matchMode), 40),
82
+ (e.matchMode || 'all').toUpperCase(),
83
+ ui.statusBadge(e.isActive !== false ? 'enabled' : 'disabled'),
84
+ ui.truncate(e.reason || '', 20),
85
+ ui.timeAgo(e.createdAt),
86
+ ];
87
+ });
88
+ ui.table(['ID', 'Scope', 'Conditions', 'Mode', 'Status', 'Reason', 'Created'], rows);
89
+ console.log('');
90
+ } catch (err) {
91
+ s.fail('Failed to fetch exclusion rules');
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ // ── Show ──
97
+
98
+ async function show(args, flags) {
99
+ requireAuth();
100
+ const id = args[0];
101
+ if (!id) {
102
+ ui.error('Exclusion ID required. Usage: securenow fp show <id>');
103
+ process.exit(1);
104
+ }
105
+
106
+ const s = ui.spinner('Fetching exclusion details');
107
+ try {
108
+ const [data, countData] = await Promise.all([
109
+ api.get(`/false-positives/exclusions/${id}`),
110
+ api.get(`/false-positives/result-count/${id}`).catch(() => null),
111
+ ]);
112
+
113
+ const excl = data.exclusion;
114
+ if (!excl) {
115
+ s.fail(`Exclusion ${id} not found`);
116
+ process.exit(1);
117
+ }
118
+
119
+ s.stop('Exclusion loaded');
120
+
121
+ if (flags.json) { ui.json({ exclusion: excl, scope: data.scope, resultCount: countData }); return; }
122
+
123
+ console.log('');
124
+ ui.heading(`Exclusion Rule: ${excl._id}`);
125
+ console.log('');
126
+ ui.keyValue([
127
+ ['ID', excl._id],
128
+ ['Active', excl.isActive !== false ? ui.c.green('Yes') : ui.c.red('No')],
129
+ ['Match Mode', (excl.matchMode || 'all').toUpperCase()],
130
+ ['Reason', excl.reason || ui.c.dim('—')],
131
+ ['Scope', data.scope === 'rule' ? `Rule: ${excl._ruleName || excl._ruleId}` : 'Global'],
132
+ ['Created', excl.createdAt ? new Date(excl.createdAt).toLocaleString() : '—'],
133
+ ]);
134
+
135
+ if (countData) {
136
+ console.log('');
137
+ ui.keyValue([
138
+ ['Matched IPs', String(countData.matchCount ?? '—')],
139
+ ]);
140
+ }
141
+
142
+ console.log('');
143
+ ui.subheading('Conditions');
144
+ console.log('');
145
+ printConditions(excl.conditions, excl.matchMode);
146
+
147
+ if (excl.pathPattern) {
148
+ console.log(`\n ${ui.c.bold('Path Pattern')}: ${excl.pathPattern}`);
149
+ }
150
+ console.log('');
151
+ } catch (err) {
152
+ if (err.statusCode === 404 || err.status === 404) {
153
+ s.fail(`Exclusion ${id} not found`);
154
+ process.exit(1);
155
+ }
156
+ s.fail('Failed to fetch exclusion');
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ // ── Create ──
162
+
163
+ async function create(args, flags) {
164
+ requireAuth();
165
+ let conditions = parseConditions(flags);
166
+
167
+ if (!conditions) {
168
+ conditions = [];
169
+ if (flags.path) {
170
+ conditions.push({ field: 'path', operator: flags['path-op'] || 'equals', value: flags.path });
171
+ }
172
+ if (flags.method) {
173
+ conditions.push({ field: 'method', operator: 'equals', value: flags.method.toUpperCase() });
174
+ }
175
+ if (flags['body-operator'] && flags['body-value']) {
176
+ conditions.push({ field: 'request_body', operator: flags['body-operator'], value: flags['body-value'] });
177
+ }
178
+ if (flags['path-safe']) {
179
+ conditions.push({ field: 'path', operator: 'path_safe_values', value: flags['path-safe'] === true ? 'standard' : flags['path-safe'] });
180
+ }
181
+ if (flags['query-safe']) {
182
+ conditions.push({ field: 'path', operator: 'query_safe_values', value: flags['query-safe'] === true ? 'standard' : flags['query-safe'] });
183
+ }
184
+ if (flags['query-keys']) {
185
+ conditions.push({ field: 'path', operator: 'query_has_only_keys', value: flags['query-keys'] });
186
+ }
187
+ if (flags['ua-safe']) {
188
+ conditions.push({ field: 'user_agent', operator: 'ua_safe_values', value: flags['ua-safe'] === true ? 'standard' : flags['ua-safe'] });
189
+ }
190
+ if (flags['headers-safe']) {
191
+ conditions.push({ field: 'request_headers', operator: 'headers_safe_values', value: flags['headers-safe'] === true ? 'standard' : flags['headers-safe'] });
192
+ }
193
+ if (flags['headers-keys']) {
194
+ conditions.push({ field: 'request_headers', operator: 'headers_has_only_keys', value: flags['headers-keys'] });
195
+ }
196
+ if (!conditions.length) {
197
+ ui.error('At least one condition is required.');
198
+ ui.info('Use --conditions \'[...]\' or --path, --method, --body-operator, --path-safe, --ua-safe, --headers-safe, etc.');
199
+ process.exit(1);
200
+ }
201
+ }
202
+
203
+ const body = {
204
+ conditions,
205
+ matchMode: flags['match-mode'] || 'all',
206
+ };
207
+ if (flags.reason) body.reason = flags.reason;
208
+
209
+ const scopeParams = parseRuleScope(flags);
210
+ const effectiveScope = scopeParams.ruleScope || 'any_rule';
211
+
212
+ if (effectiveScope === 'this_rule') {
213
+ ui.error('--rule-scope this_rule requires a notification context. Use "securenow fp mark" or --rule-scope specific_rules with --target-rules.');
214
+ process.exit(1);
215
+ }
216
+
217
+ body.ruleScope = effectiveScope;
218
+ if (scopeParams.targetRuleIds) body.targetRuleIds = scopeParams.targetRuleIds;
219
+
220
+ const s = ui.spinner('Creating exclusion rule');
221
+ try {
222
+ const data = await api.post('/false-positives/exclusions', body);
223
+
224
+ s.stop('Exclusion rule created');
225
+
226
+ if (flags.json) { ui.json(data); return; }
227
+
228
+ console.log('');
229
+ if (effectiveScope === 'any_rule') {
230
+ ui.success(`Created global exclusion: ${data.exclusion?._id || '(unknown ID)'}`);
231
+ } else {
232
+ const ruleCount = data.rules?.length || 0;
233
+ ui.success(`Created exclusion on ${ruleCount} rule${ruleCount !== 1 ? 's' : ''}`);
234
+ if (data.rules) {
235
+ data.rules.forEach(r => {
236
+ console.log(` ${ui.c.dim('•')} ${r.ruleName || r.ruleId}`);
237
+ });
238
+ }
239
+ }
240
+ console.log('');
241
+ ui.keyValue([['Scope', scopeLabel(effectiveScope)]]);
242
+ console.log('');
243
+ printConditions(conditions, body.matchMode);
244
+ if (flags.reason) console.log(`\n Reason: ${flags.reason}`);
245
+ console.log('');
246
+ } catch (err) {
247
+ s.fail('Failed to create exclusion rule');
248
+ throw err;
249
+ }
250
+ }
251
+
252
+ // ── Edit ──
253
+
254
+ async function edit(args, flags) {
255
+ requireAuth();
256
+ const id = args[0];
257
+ if (!id) {
258
+ ui.error('Exclusion ID required. Usage: securenow fp edit <id> [--active true/false] [--reason "..."]');
259
+ process.exit(1);
260
+ }
261
+
262
+ const body = {};
263
+ if (flags.active != null) body.isActive = flags.active === 'true' || flags.active === true;
264
+ if (flags.reason) body.reason = flags.reason;
265
+ if (flags['match-mode']) body.matchMode = flags['match-mode'];
266
+ const conditions = parseConditions(flags);
267
+ if (conditions) body.conditions = conditions;
268
+
269
+ if (!Object.keys(body).length) {
270
+ ui.error('Nothing to update. Use --active, --reason, --match-mode, or --conditions.');
271
+ process.exit(1);
272
+ }
273
+
274
+ const s = ui.spinner(`Updating exclusion ${id}`);
275
+ try {
276
+ const data = await api.put(`/false-positives/exclusions/${id}`, body);
277
+ s.stop('Exclusion updated');
278
+
279
+ if (flags.json) { ui.json(data); return; }
280
+
281
+ console.log('');
282
+ ui.success(`Updated exclusion: ${id}`);
283
+ if (data.exclusion) {
284
+ ui.keyValue([
285
+ ['Active', data.exclusion.isActive !== false ? ui.c.green('Yes') : ui.c.red('No')],
286
+ ['Match Mode', (data.exclusion.matchMode || 'all').toUpperCase()],
287
+ ['Reason', data.exclusion.reason || ui.c.dim('—')],
288
+ ]);
289
+ }
290
+ console.log('');
291
+ } catch (err) {
292
+ s.fail('Failed to update exclusion');
293
+ throw err;
294
+ }
295
+ }
296
+
297
+ // ── Delete ──
298
+
299
+ async function remove(args, flags) {
300
+ requireAuth();
301
+ const id = args[0];
302
+ if (!id) {
303
+ ui.error('Exclusion ID required. Usage: securenow fp delete <id>');
304
+ process.exit(1);
305
+ }
306
+
307
+ if (!flags.force && !flags.yes) {
308
+ const ok = await ui.confirm(`Delete exclusion ${id}?`);
309
+ if (!ok) { ui.info('Cancelled'); return; }
310
+ }
311
+
312
+ const s = ui.spinner(`Deleting exclusion ${id}`);
313
+ try {
314
+ await api.delete(`/false-positives/exclusions/${id}`);
315
+ s.stop('Exclusion deleted');
316
+ } catch (err) {
317
+ s.fail('Failed to delete exclusion');
318
+ throw err;
319
+ }
320
+ }
321
+
322
+ // ── Test Body ──
323
+
324
+ async function testBody(args, flags) {
325
+ requireAuth();
326
+ const bodyStr = readBody(args, flags);
327
+ if (!bodyStr) {
328
+ ui.error('Request body is required. Pass as argument, --body, @filepath, or pipe via stdin.');
329
+ ui.info('Example: securenow fp test-body \'{"key":"value"}\' --conditions \'[...]\'');
330
+ process.exit(1);
331
+ }
332
+
333
+ const conditions = parseConditions(flags);
334
+ if (!conditions || !conditions.length) {
335
+ ui.error('--conditions is required with at least one request_body condition.');
336
+ process.exit(1);
337
+ }
338
+
339
+ const s = ui.spinner('Testing body against conditions');
340
+ try {
341
+ const data = await api.post('/false-positives/test-body', { body: bodyStr, conditions });
342
+ s.stop('Test complete');
343
+
344
+ if (flags.json) { ui.json(data); return; }
345
+
346
+ console.log('');
347
+ const verdict = data.allPassed
348
+ ? ui.c.green('✓ ALL PASSED — this body would be excluded (false positive)')
349
+ : ui.c.red('✗ FAILED — this body would still trigger alerts');
350
+ console.log(` ${verdict}`);
351
+ console.log(` Detected format: ${ui.c.cyan(data.detectedFormat || 'unknown')}`);
352
+ console.log('');
353
+
354
+ if (data.results) {
355
+ const rows = data.results.map(r => {
356
+ const status = r.passed ? ui.c.green('✓ pass') : ui.c.red('✗ fail');
357
+ const detail = r.details?.error
358
+ || (r.details?.threats?.length ? `threats: ${r.details.threats.map(t => t.attacks?.join(',')).join('; ')}` : '')
359
+ || (r.details?.extraKeys?.length ? `extra keys: ${r.details.extraKeys.join(', ')}` : '')
360
+ || '';
361
+ return [
362
+ status,
363
+ r.condition?.operator || '—',
364
+ ui.truncate(r.condition?.value || '', 40),
365
+ ui.truncate(detail, 40),
366
+ ];
367
+ });
368
+ ui.table(['Result', 'Operator', 'Value', 'Details'], rows);
369
+ }
370
+ console.log('');
371
+
372
+ if (!data.allPassed) process.exit(2);
373
+ } catch (err) {
374
+ s.fail('Body test failed');
375
+ throw err;
376
+ }
377
+ }
378
+
379
+ // ── Dry Run ──
380
+
381
+ async function dryRun(args, flags) {
382
+ requireAuth();
383
+ const conditions = parseConditions(flags);
384
+ if (!conditions || !conditions.length) {
385
+ ui.error('--conditions is required.');
386
+ ui.info('Example: securenow fp dry-run --conditions \'[{"field":"path","operator":"equals","value":"/api/event"},{"field":"method","operator":"equals","value":"POST"}]\'');
387
+ process.exit(1);
388
+ }
389
+
390
+ const body = { conditions, matchMode: flags['match-mode'] || 'all' };
391
+ const s = ui.spinner('Running dry-run against live traces (last 3 days)');
392
+ try {
393
+ const data = await api.post('/false-positives/dry-run', body);
394
+ s.stop('Dry-run complete');
395
+
396
+ if (flags.json) { ui.json(data); return; }
397
+
398
+ const total = data.totalTraces || 0;
399
+ const matched = data.matchedCount || 0;
400
+ const unmatched = data.unmatchedCount || 0;
401
+ const pct = total > 0 ? Math.round((matched / total) * 100) : 0;
402
+
403
+ console.log('');
404
+ ui.heading('Dry-Run Results');
405
+ console.log('');
406
+ ui.keyValue([
407
+ ['Total Traces', String(total)],
408
+ ['Would Be Excluded', `${ui.c.green(String(matched))} (${pct}%)`],
409
+ ['Would Still Alert', `${unmatched > 0 ? ui.c.red(String(unmatched)) : ui.c.green('0')} (${100 - pct}%)`],
410
+ ]);
411
+
412
+ if (data.queryLimitHit) {
413
+ console.log(`\n ${ui.c.yellow('!')} Query limit reached (5,000 spans). Results are a sample.`);
414
+ }
415
+
416
+ if (data.sqlPreFiltered?.length) {
417
+ console.log(`\n ${ui.c.dim('Pre-filtered in DB:')} ${data.sqlPreFiltered.join(' + ')}`);
418
+ }
419
+
420
+ // Aggregated body keys
421
+ if (data.aggregatedBodyKeys?.length) {
422
+ const keyCond = conditions.find(c =>
423
+ c.field === 'request_body' && (c.operator === 'json_has_only_keys' || c.operator === 'body_has_only_fields')
424
+ );
425
+ if (keyCond) {
426
+ const expected = new Set((keyCond.value || '').split(',').map(k => k.trim()).filter(Boolean));
427
+ const extra = data.aggregatedBodyKeys.filter(k => !expected.has(k));
428
+ if (extra.length) {
429
+ console.log('');
430
+ ui.subheading(`Keys in bodies NOT in allowlist (${extra.length})`);
431
+ console.log('');
432
+ extra.forEach(k => console.log(` ${ui.c.yellow('+')} ${k}`));
433
+ console.log(`\n ${ui.c.dim('Copy all keys:')} ${data.aggregatedBodyKeys.join(',')}`);
434
+ }
435
+ }
436
+ }
437
+
438
+ // Unmatched traces
439
+ if (data.unmatchedTraces?.length) {
440
+ console.log('');
441
+ ui.subheading(`Unmatched Traces (showing ${data.unmatchedTraces.length} of ${unmatched})`);
442
+ console.log('');
443
+ const rows = data.unmatchedTraces.map(t => [
444
+ ui.c.dim(ui.truncate(t.traceId, 16)),
445
+ t.method || '—',
446
+ ui.truncate(t.path || '', 30),
447
+ t.statusCode || '—',
448
+ ui.truncate((t.failedConditions || []).join(', '), 35),
449
+ ]);
450
+ ui.table(['Trace ID', 'Method', 'Path', 'Status', 'Failed Conditions'], rows);
451
+ }
452
+
453
+ if (total > 0 && unmatched === 0) {
454
+ console.log(`\n ${ui.c.green('✓')} All ${total} traces match — they would all be excluded.`);
455
+ }
456
+ console.log('');
457
+
458
+ if (unmatched > 0) process.exit(2);
459
+ } catch (err) {
460
+ s.fail('Dry-run failed');
461
+ throw err;
462
+ }
463
+ }
464
+
465
+ // ── AI Fill ──
466
+
467
+ async function aiFill(args, flags) {
468
+ requireAuth();
469
+ const description = args.join(' ') || flags.description;
470
+ let httpContext = null;
471
+
472
+ if (flags.context) {
473
+ try {
474
+ httpContext = JSON.parse(flags.context);
475
+ } catch {
476
+ ui.error('--context must be valid JSON, e.g. \'{"method":"POST","url":"/api/event","requestBody":"..."}\'');
477
+ process.exit(1);
478
+ }
479
+ }
480
+
481
+ if (!description && !httpContext) {
482
+ ui.error('Provide a --description or --context for AI to generate conditions.');
483
+ ui.info('Example: securenow fp ai-fill --description "view_cart events from /api/event"');
484
+ process.exit(1);
485
+ }
486
+
487
+ const body = {};
488
+ if (description) body.description = description;
489
+ if (httpContext) body.httpContext = httpContext;
490
+
491
+ const s = ui.spinner('Generating conditions with AI');
492
+ try {
493
+ const data = await api.post('/false-positives/ai-fill', body);
494
+ s.stop('AI suggestion ready');
495
+
496
+ if (flags.json) { ui.json(data); return; }
497
+
498
+ console.log('');
499
+ ui.heading('AI-Generated Exclusion');
500
+ console.log('');
501
+
502
+ if (data.reason) {
503
+ console.log(` ${ui.c.bold('Reason:')} ${data.reason}`);
504
+ console.log('');
505
+ }
506
+
507
+ console.log(` ${ui.c.bold('Match Mode:')} ${(data.matchMode || 'all').toUpperCase()}`);
508
+ console.log('');
509
+
510
+ ui.subheading('Conditions');
511
+ console.log('');
512
+ printConditions(data.conditions, data.matchMode);
513
+
514
+ console.log('');
515
+ ui.subheading('Use with create');
516
+ const condStr = JSON.stringify(data.conditions);
517
+ console.log(`\n securenow fp create --conditions '${condStr}'${data.reason ? ` --reason "${data.reason}"` : ''}`);
518
+ console.log('');
519
+ } catch (err) {
520
+ s.fail('AI generation failed');
521
+ throw err;
522
+ }
523
+ }
524
+
525
+ // ── Mark False Positive ──
526
+
527
+ const VALID_RULE_SCOPES = ['this_rule', 'specific_rules', 'all_existing', 'any_rule'];
528
+
529
+ function parseRuleScope(flags) {
530
+ const scope = flags['rule-scope'];
531
+ if (!scope) return {};
532
+ if (!VALID_RULE_SCOPES.includes(scope)) {
533
+ ui.error(`--rule-scope must be one of: ${VALID_RULE_SCOPES.join(', ')}`);
534
+ process.exit(1);
535
+ }
536
+ const result = { ruleScope: scope };
537
+ if (scope === 'specific_rules') {
538
+ const raw = flags['target-rules'];
539
+ if (!raw) {
540
+ ui.error('--target-rules is required when --rule-scope is specific_rules (comma-separated rule IDs)');
541
+ process.exit(1);
542
+ }
543
+ result.targetRuleIds = raw.split(',').map(id => id.trim()).filter(Boolean);
544
+ if (!result.targetRuleIds.length) {
545
+ ui.error('--target-rules must contain at least one rule ID');
546
+ process.exit(1);
547
+ }
548
+ }
549
+ return result;
550
+ }
551
+
552
+ function scopeLabel(scope) {
553
+ switch (scope) {
554
+ case 'this_rule': return 'This rule only';
555
+ case 'specific_rules': return 'Specific rules';
556
+ case 'all_existing': return 'All existing rules';
557
+ case 'any_rule': return 'Any rule (incl. future)';
558
+ default: return scope || 'This rule only';
559
+ }
560
+ }
561
+
562
+ async function mark(args, flags) {
563
+ requireAuth();
564
+ const notifId = args[0];
565
+ const ip = args[1];
566
+
567
+ if (!notifId || !ip) {
568
+ ui.error('Usage: securenow fp mark <notification-id> <ip> [--conditions \'[...]\'] [--reason "..."] [--rule-scope this_rule|specific_rules|all_existing|any_rule]');
569
+ process.exit(1);
570
+ }
571
+
572
+ const body = {};
573
+ const conditions = parseConditions(flags);
574
+ if (conditions) body.conditions = conditions;
575
+ if (flags['match-mode']) body.matchMode = flags['match-mode'];
576
+ if (flags.reason) body.reason = flags.reason;
577
+ body.createExclusion = flags['create-exclusion'] !== 'false' && flags['create-exclusion'] !== false;
578
+ body.applyToExisting = flags['apply-existing'] !== 'false' && flags['apply-existing'] !== false;
579
+
580
+ const scopeParams = parseRuleScope(flags);
581
+ Object.assign(body, scopeParams);
582
+
583
+ const s = ui.spinner(`Marking ${ip} as false positive`);
584
+ try {
585
+ const data = await api.post(`/notifications/${encodeURIComponent(notifId)}/ips/${encodeURIComponent(ip)}/false-positive`, body);
586
+ s.stop('Marked as false positive');
587
+
588
+ if (flags.json) { ui.json(data); return; }
589
+
590
+ console.log('');
591
+ ui.success(`IP ${ip} marked as false positive`);
592
+ console.log('');
593
+ ui.keyValue([
594
+ ['Notification', notifId],
595
+ ['IP', ip],
596
+ ['Rule Scope', scopeLabel(data.ruleScope)],
597
+ ['IPs Dismissed (same notif)', String(data.bulkCount ?? 0)],
598
+ ['Cross-Notification Matches', String(data.crossNotifCount ?? 0)],
599
+ ['Exclusion Rule Created', data.exclusionCreated ? ui.c.green('Yes') : ui.c.dim('No')],
600
+ ]);
601
+
602
+ if (data.exclusionCreated?.rules?.length > 0) {
603
+ console.log('');
604
+ ui.subheading(`Exclusion added to ${data.exclusionCreated.rules.length} rule${data.exclusionCreated.rules.length !== 1 ? 's' : ''}`);
605
+ console.log('');
606
+ data.exclusionCreated.rules.forEach(r => {
607
+ console.log(` ${ui.c.dim('•')} ${r.ruleName} ${ui.c.dim(`(${r.ruleId})`)}`);
608
+ });
609
+ }
610
+
611
+ if (data.crossNotifDetails?.length) {
612
+ console.log('');
613
+ ui.subheading('Affected Notifications');
614
+ console.log('');
615
+ const rows = data.crossNotifDetails.map(d => [
616
+ ui.truncate(d.title || d.notificationId, 40),
617
+ String(d.dismissedCount),
618
+ ]);
619
+ ui.table(['Notification', 'Dismissed'], rows);
620
+ }
621
+ console.log('');
622
+ } catch (err) {
623
+ s.fail('Failed to mark as false positive');
624
+ throw err;
625
+ }
626
+ }
627
+
628
+ module.exports = {
629
+ list,
630
+ show,
631
+ create,
632
+ edit,
633
+ remove,
634
+ testBody,
635
+ dryRun,
636
+ aiFill,
637
+ mark,
638
+ };