securenow 5.10.2 → 5.11.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/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
+ };
package/cli/security.js CHANGED
@@ -775,16 +775,11 @@ async function analytics(args, flags) {
775
775
  const appKey = resolveApp(flags);
776
776
  if (flags.instance) query.instanceId = flags.instance;
777
777
 
778
- const endpoints = ['2xx-responses', '3xx-responses', '4xx-responses', '5xx-responses', '500-errors'];
779
- const results = await Promise.all(
780
- endpoints.map(ep => api.get(`/analytics/${ep}`, { query }).catch(() => null))
781
- );
778
+ const data = await api.get('/analytics/summary', { query });
782
779
 
783
780
  s.stop('Analytics loaded');
784
781
 
785
782
  if (flags.json) {
786
- const data = {};
787
- endpoints.forEach((ep, i) => { data[ep] = results[i]; });
788
783
  ui.json(data);
789
784
  return;
790
785
  }
@@ -793,8 +788,9 @@ async function analytics(args, flags) {
793
788
  ui.heading('Analytics Overview');
794
789
  console.log('');
795
790
 
796
- const pairs = endpoints.map((ep, i) => {
797
- const val = results[i];
791
+ const groups = ['2xx-responses', '3xx-responses', '4xx-responses', '5xx-responses', '500-errors'];
792
+ const pairs = groups.map(ep => {
793
+ const val = data[ep];
798
794
  const count = val?.meta?.count ?? '—';
799
795
  return [ep, String(count)];
800
796
  });