z-shield 0.0.0-stage → 0.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.
package/src/cli.js ADDED
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const net = require('node:net');
7
+ const readline = require('node:readline');
8
+
9
+ const { stripAnsi, sanitizeString } = require('./logger');
10
+ const { parseDuration, isValidIp } = require('./block-manager');
11
+
12
+ /**
13
+ * Parses raw command-line arguments into a structured command object.
14
+ *
15
+ * @param {string[]} argv - Array of CLI arguments (typically process.argv.slice(2))
16
+ * @returns {{ command: string, args: string[], options: Record<string, string|boolean> }}
17
+ */
18
+ function parseCliArgs(argv = []) {
19
+ let command = 'help';
20
+ const args = [];
21
+ const options = {};
22
+
23
+ let i = 0;
24
+ // If first non-flag argument exists, it's the command
25
+ while (i < argv.length) {
26
+ const token = argv[i];
27
+ if (token.startsWith('--') || token.startsWith('-')) {
28
+ const cleanKey = token.replace(/^-+/, '');
29
+ // Check if next token is value
30
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
31
+ options[cleanKey] = argv[i + 1];
32
+ i += 2;
33
+ } else {
34
+ options[cleanKey] = true;
35
+ i += 1;
36
+ }
37
+ } else {
38
+ if (command === 'help') {
39
+ command = token.toLowerCase();
40
+ } else {
41
+ args.push(token);
42
+ }
43
+ i += 1;
44
+ }
45
+ }
46
+
47
+ if (options.help || options.h) {
48
+ command = 'help';
49
+ }
50
+
51
+ return { command, args, options };
52
+ }
53
+
54
+ /**
55
+ * Resolves the target JSON Lines log file path based on CLI options or defaults.
56
+ *
57
+ * @param {Record<string, any>} options
58
+ * @returns {string} Absolute path to events.jsonl
59
+ */
60
+ function resolveLogPath(options = {}) {
61
+ const logDir = path.resolve(options['log-dir'] || options.logDir || path.join(process.cwd(), '.z-shield-logs'));
62
+ const filename = options['log-file'] || options.logFile || 'events.jsonl';
63
+ return path.join(logDir, filename);
64
+ }
65
+
66
+ /**
67
+ * Creates an ANSI color styling helper object.
68
+ * When enabled is false, all styling methods act as plain passthroughs.
69
+ *
70
+ * @param {boolean} [enabled=true]
71
+ * @returns {Record<string, (str: string|number) => string>}
72
+ */
73
+ function createColors(enabled = true) {
74
+ if (!enabled) {
75
+ const pass = (str) => String(str ?? '');
76
+ return {
77
+ bold: pass,
78
+ dim: pass,
79
+ italic: pass,
80
+ underline: pass,
81
+ inverse: pass,
82
+ red: pass,
83
+ green: pass,
84
+ yellow: pass,
85
+ blue: pass,
86
+ magenta: pass,
87
+ cyan: pass,
88
+ white: pass,
89
+ gray: pass,
90
+ enabled: false,
91
+ hasColors: false
92
+ };
93
+ }
94
+
95
+ const wrap = (open, close) => (str) => `\x1b[${open}m${str ?? ''}\x1b[${close}m`;
96
+
97
+ return {
98
+ bold: wrap(1, 22),
99
+ dim: wrap(2, 22),
100
+ italic: wrap(3, 23),
101
+ underline: wrap(4, 24),
102
+ inverse: wrap(7, 27),
103
+ red: wrap(31, 39),
104
+ green: wrap(32, 39),
105
+ yellow: wrap(33, 39),
106
+ blue: wrap(34, 39),
107
+ magenta: wrap(35, 39),
108
+ cyan: wrap(36, 39),
109
+ white: wrap(37, 39),
110
+ gray: wrap(90, 39),
111
+ enabled: true,
112
+ hasColors: true
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Determines whether terminal colors should be enabled based on
118
+ * explicit options, environment variables, and TTY capabilities.
119
+ *
120
+ * Respects the standard NO_COLOR specification (https://no-color.org).
121
+ *
122
+ * @param {Record<string, any>} [options={}]
123
+ * @param {Console|Object} [output=null]
124
+ * @returns {boolean}
125
+ */
126
+ function resolveColorSupport(options = {}, output = null) {
127
+ // 1. Explicit CLI flags
128
+ if (
129
+ options['no-color'] ||
130
+ options.noColor ||
131
+ options.color === false ||
132
+ options.colors === false ||
133
+ options.color === 'false' ||
134
+ options.colors === 'false'
135
+ ) {
136
+ return false;
137
+ }
138
+ if (
139
+ options.color === true ||
140
+ options.colors === true ||
141
+ options.color === 'true' ||
142
+ options.colors === 'true' ||
143
+ options.color === ''
144
+ ) {
145
+ return true;
146
+ }
147
+
148
+ // 2. NO_COLOR standard (https://no-color.org)
149
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
150
+ return false;
151
+ }
152
+
153
+ // 3. Node.js NODE_DISABLE_COLORS
154
+ if (process.env.NODE_DISABLE_COLORS === '1') {
155
+ return false;
156
+ }
157
+
158
+ // 4. FORCE_COLOR override
159
+ if (process.env.FORCE_COLOR !== undefined) {
160
+ return process.env.FORCE_COLOR !== '0';
161
+ }
162
+
163
+ // 5. If output is a custom test mock or stream without TTY, disable colors by default
164
+ if (output && output !== console) {
165
+ return Boolean(output.isTTY);
166
+ }
167
+
168
+ // 6. Interactive terminal check
169
+ return Boolean(process.stdout && process.stdout.isTTY);
170
+ }
171
+
172
+ /**
173
+ * Formats a single security event record into a clean, sanitized terminal line.
174
+ * Strips all ANSI escapes and control characters from untrusted log fields before formatting.
175
+ *
176
+ * @param {Object} event
177
+ * @param {Object|boolean} [options={}] - Options object or boolean for color
178
+ * @returns {string} Sanitized output line
179
+ */
180
+ function formatLogEvent(event, options = {}) {
181
+ if (!event || typeof event !== 'object') {
182
+ return '[Malformed Event]';
183
+ }
184
+
185
+ let useColors = false;
186
+ if (typeof options === 'boolean') {
187
+ useColors = options;
188
+ } else if (options && typeof options === 'object') {
189
+ if (typeof options.colors === 'boolean') {
190
+ useColors = options.colors;
191
+ } else if (typeof options.color === 'boolean') {
192
+ useColors = options.color;
193
+ } else if (options.colors === 'true' || options.color === 'true') {
194
+ useColors = true;
195
+ } else if (options.colors === 'false' || options.color === 'false' || options['no-color']) {
196
+ useColors = false;
197
+ }
198
+ }
199
+ const c = createColors(useColors);
200
+
201
+ // Sanitize untrusted log values first to prevent terminal injection
202
+ const timestamp = sanitizeString(event.timestamp || 'UNKNOWN-TIME', 30);
203
+ const severity = sanitizeString((event.severity || 'INFO').toUpperCase(), 10);
204
+ const type = sanitizeString(event.type || 'unknown', 20);
205
+ const rawAction = (event.action || 'unknown').toUpperCase();
206
+ const normalizedAction = rawAction === 'WARN' ? 'WARNED'
207
+ : rawAction === 'BLOCK' ? 'BLOCKED'
208
+ : rawAction === 'ALLOW' ? 'ALLOWED'
209
+ : rawAction;
210
+ const action = sanitizeString(normalizedAction, 10);
211
+ const ip = sanitizeString(event.ip || 'unknown', 45);
212
+ const method = sanitizeString((event.method || 'GET').toUpperCase(), 10);
213
+ const route = sanitizeString(event.route || '/', 100);
214
+ const reason = sanitizeString(event.reason || 'No details', 150);
215
+
216
+ if (!useColors) {
217
+ return `[${timestamp}] [${severity}] [${action}] ${method} ${route} | IP: ${ip} | ${type}: ${reason}`;
218
+ }
219
+
220
+ // Action badge with semantic color
221
+ let styledAction;
222
+ switch (action) {
223
+ case 'BLOCKED': styledAction = c.bold(c.red(`[${action}]`)); break;
224
+ case 'WARNED': styledAction = c.bold(c.yellow(`[${action}]`)); break;
225
+ case 'ALLOWED': styledAction = c.bold(c.green(`[${action}]`)); break;
226
+ case 'UNBLOCKED': styledAction = c.bold(c.cyan(`[${action}]`)); break;
227
+ case 'REQUESTED': styledAction = c.bold(c.blue(`[${action}]`)); break;
228
+ default: styledAction = c.bold(`[${action}]`); break;
229
+ }
230
+
231
+ // Severity badge with semantic color
232
+ let styledSeverity;
233
+ switch (severity) {
234
+ case 'CRITICAL': styledSeverity = c.bold(c.red(`[${severity}]`)); break;
235
+ case 'WARNING': styledSeverity = c.bold(c.yellow(`[${severity}]`)); break;
236
+ case 'INFO': styledSeverity = c.bold(c.green(`[${severity}]`)); break;
237
+ default: styledSeverity = c.bold(`[${severity}]`); break;
238
+ }
239
+
240
+ const styledTimestamp = c.gray(`[${timestamp}]`);
241
+ const styledMethod = c.bold(method);
242
+ const styledRoute = c.cyan(route);
243
+ const styledIp = c.bold(ip);
244
+ const styledType = c.yellow(type);
245
+
246
+ return `${styledTimestamp} ${styledSeverity} ${styledAction} ${styledMethod} ${styledRoute} | IP: ${styledIp} | ${styledType}: ${reason}`;
247
+ }
248
+
249
+ /**
250
+ * Reads and parses the last N records from the JSON Lines file.
251
+ * Handles missing files and skips malformed records gracefully.
252
+ *
253
+ * @param {string} logFilePath
254
+ * @param {number} [limit=20]
255
+ * @returns {{ events: Object[], totalLines: number, malformedCount: number, fileExists: boolean }}
256
+ */
257
+ function readLogs(logFilePath, limit = 20) {
258
+ if (!fs.existsSync(logFilePath)) {
259
+ return { events: [], totalLines: 0, malformedCount: 0, fileExists: false };
260
+ }
261
+
262
+ const rawContent = fs.readFileSync(logFilePath, 'utf8');
263
+ const rawLines = rawContent.split('\n').filter(line => line.trim().length > 0);
264
+
265
+ let malformedCount = 0;
266
+ const parsedEvents = [];
267
+
268
+ for (const line of rawLines) {
269
+ try {
270
+ const parsed = JSON.parse(line);
271
+ parsedEvents.push(parsed);
272
+ } catch {
273
+ malformedCount++;
274
+ }
275
+ }
276
+
277
+ const selected = parsedEvents.slice(-limit);
278
+
279
+ return {
280
+ events: selected,
281
+ totalLines: rawLines.length,
282
+ malformedCount,
283
+ fileExists: true
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Computes security summary statistics from the log file.
289
+ *
290
+ * @param {string} logFilePath
291
+ * @returns {Object}
292
+ */
293
+ function calculateStatus(logFilePath) {
294
+ if (!fs.existsSync(logFilePath)) {
295
+ return { fileExists: false, totalEvents: 0 };
296
+ }
297
+
298
+ const { events: allEvents, malformedCount } = readLogs(logFilePath, 100000);
299
+
300
+ const ipCounts = {};
301
+ const routeCounts = {};
302
+ const typeCounts = {};
303
+ const actionCounts = {};
304
+
305
+ for (const event of allEvents) {
306
+ const ip = event.ip || 'unknown';
307
+ const route = event.route || '/';
308
+ const type = event.type || 'unknown';
309
+ const action = event.action || 'unknown';
310
+
311
+ ipCounts[ip] = (ipCounts[ip] || 0) + 1;
312
+ routeCounts[route] = (routeCounts[route] || 0) + 1;
313
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
314
+ actionCounts[action] = (actionCounts[action] || 0) + 1;
315
+ }
316
+
317
+ // Sort top offenders
318
+ const topIps = Object.entries(ipCounts)
319
+ .sort((a, b) => b[1] - a[1])
320
+ .slice(0, 5)
321
+ .map(([ip, count]) => ({ ip, count }));
322
+
323
+ const topRoutes = Object.entries(routeCounts)
324
+ .sort((a, b) => b[1] - a[1])
325
+ .slice(0, 5)
326
+ .map(([route, count]) => ({ route, count }));
327
+
328
+ return {
329
+ fileExists: true,
330
+ totalEvents: allEvents.length,
331
+ malformedCount,
332
+ typeCounts,
333
+ actionCounts,
334
+ topIps,
335
+ topRoutes
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Main command runner. Returns an exit code (0 for success, 1 for error).
341
+ *
342
+ * @param {string[]} argv
343
+ * @param {Console|Object} [output=console]
344
+ * @returns {Promise<number>} Exit code
345
+ */
346
+ async function runCommand(argv = process.argv.slice(2), output = console) {
347
+ const { command, args, options } = parseCliArgs(argv);
348
+ const logFilePath = resolveLogPath(options);
349
+ const useColors = resolveColorSupport(options, output);
350
+ const c = createColors(useColors);
351
+
352
+ switch (command) {
353
+ case 'status': {
354
+ output.log(c.cyan('\n\n===================================================='));
355
+ output.log(c.bold(c.cyan('🛡️ Z-Shield Security Status')));
356
+ output.log(c.cyan('===================================================='));
357
+ output.log(`${c.bold('Log File:')} ${c.gray(logFilePath)}`);
358
+
359
+ const status = calculateStatus(logFilePath);
360
+
361
+ if (!status.fileExists) {
362
+ output.log(`${c.bold('Status:')} ${c.yellow('No log file found yet.')}`);
363
+ output.log(c.dim('ℹ Start your Express server to begin logging security events.'));
364
+ output.log(c.cyan('====================================================\n'));
365
+ return 0;
366
+ }
367
+
368
+ output.log(`${c.bold('Total Events Recorded:')} ${c.bold(c.cyan(status.totalEvents))}`);
369
+ if (status.malformedCount > 0) {
370
+ output.log(`${c.bold(c.yellow('⚠️ Malformed Lines Skipped:'))} ${c.yellow(status.malformedCount)}`);
371
+ }
372
+
373
+ output.log(`\n${c.bold('Actions Breakdown:')}`);
374
+ for (const [action, count] of Object.entries(status.actionCounts)) {
375
+ const upper = action.toUpperCase();
376
+ let styledAction = upper;
377
+ if (upper === 'BLOCKED') styledAction = c.bold(c.red(upper));
378
+ else if (upper === 'WARNED') styledAction = c.bold(c.yellow(upper));
379
+ else if (upper === 'ALLOWED') styledAction = c.bold(c.green(upper));
380
+ else if (upper === 'UNBLOCKED') styledAction = c.bold(c.cyan(upper));
381
+ else if (upper === 'REQUESTED') styledAction = c.bold(c.blue(upper));
382
+ output.log(` - ${styledAction}: ${c.bold(count)}`);
383
+ }
384
+
385
+ output.log(`\n${c.bold('Event Types:')}`);
386
+ for (const [type, count] of Object.entries(status.typeCounts)) {
387
+ output.log(` - ${c.yellow(type)}: ${c.bold(count)}`);
388
+ }
389
+
390
+ if (status.topIps.length > 0) {
391
+ output.log(`\n${c.bold('Top Offending IPs:')}`);
392
+ for (const item of status.topIps) {
393
+ output.log(` - ${c.cyan(item.ip)}: ${c.bold(item.count)} events`);
394
+ }
395
+ }
396
+
397
+ if (status.topRoutes.length > 0) {
398
+ output.log(`\n${c.bold('Top Targeted Routes:')}`);
399
+ for (const item of status.topRoutes) {
400
+ output.log(` - ${c.underline(item.route)}: ${c.bold(item.count)} events`);
401
+ }
402
+ }
403
+
404
+ output.log(`\n${c.dim('ℹ Process-Local Architecture Notice:')}`);
405
+ output.log(c.dim('Statistics reflect historical events recorded in the local log file.'));
406
+ output.log(c.dim('Active in-memory counters and temporary blocks reside in server RAM'));
407
+ output.log(c.dim('and are managed inside the running Express process.'));
408
+
409
+ output.log(c.cyan('===================================================='));
410
+ return 0;
411
+ }
412
+
413
+ case 'logs': {
414
+ let limit = 20;
415
+ if (options.limit) {
416
+ const parsed = parseInt(options.limit, 10);
417
+ if (isNaN(parsed) || parsed <= 0) {
418
+ output.error(c.bold(c.red('[ERROR] --limit must be a positive integer.')));
419
+ return 1;
420
+ }
421
+ limit = parsed;
422
+ }
423
+
424
+ const { events, totalLines, malformedCount, fileExists } = readLogs(logFilePath, limit);
425
+
426
+ output.log(c.cyan('\n\n===================================================='));
427
+ output.log(c.bold(c.cyan(`🛡️ Z-Shield Security Logs (Showing last ${events.length} of ${totalLines} events)`)));
428
+ output.log(`${c.bold('Source:')} ${c.gray(logFilePath)}`);
429
+ output.log(c.cyan('===================================================='));
430
+
431
+ if (!fileExists || events.length === 0) {
432
+ output.log(c.dim('ℹ No security events found in log file.'));
433
+ output.log(c.cyan('===================================================='));
434
+ return 0;
435
+ }
436
+
437
+ for (const event of events) {
438
+ output.log(formatLogEvent(event, { colors: useColors }));
439
+ }
440
+
441
+ if (malformedCount > 0) {
442
+ output.log(`\n${c.yellow(`⚠️ Notice: Skipped ${malformedCount} unparseable or corrupted lines.`)}`);
443
+ }
444
+
445
+ output.log(c.cyan('===================================================='));
446
+ return 0;
447
+ }
448
+
449
+ case 'monitor': {
450
+ output.log(c.cyan('\n\n===================================================='));
451
+ output.log(c.bold(c.cyan('🛡️ Z-Shield Live Security Event Monitor')));
452
+ output.log(`${c.bold('Watching:')} ${c.gray(logFilePath)}`);
453
+ output.log(c.dim('Press Ctrl+C to exit.'));
454
+ output.log(c.cyan('===================================================='));
455
+
456
+ let currentSize = 0;
457
+ if (fs.existsSync(logFilePath)) {
458
+ currentSize = fs.statSync(logFilePath).size;
459
+ // Show recent events for immediate context
460
+ const { events } = readLogs(logFilePath, 5);
461
+ if (events.length > 0) {
462
+ output.log(c.dim('--- Recent Events ---'));
463
+ for (const ev of events) {
464
+ output.log(formatLogEvent(ev, { colors: useColors }));
465
+ }
466
+ output.log(c.dim('--- Live Stream Active ---'));
467
+ }
468
+ } else {
469
+ output.log(c.dim('ℹ Waiting for server to create log file...'));
470
+ }
471
+
472
+ // Live watcher polling function
473
+ const checkNewLines = () => {
474
+ if (!fs.existsSync(logFilePath)) {
475
+ return;
476
+ }
477
+ try {
478
+ const stats = fs.statSync(logFilePath);
479
+ if (stats.size > currentSize) {
480
+ const stream = fs.createReadStream(logFilePath, {
481
+ start: currentSize,
482
+ end: stats.size,
483
+ encoding: 'utf8'
484
+ });
485
+
486
+ currentSize = stats.size;
487
+
488
+ let buffer = '';
489
+ stream.on('data', chunk => {
490
+ buffer += chunk;
491
+ const lines = buffer.split('\n');
492
+ buffer = lines.pop(); // keep last partial line
493
+ for (const line of lines) {
494
+ if (line.trim().length > 0) {
495
+ try {
496
+ const parsed = JSON.parse(line);
497
+ output.log(formatLogEvent(parsed, { colors: useColors }));
498
+ } catch {
499
+ output.log(c.yellow('[⚠️ Corrupted log record]'));
500
+ }
501
+ }
502
+ }
503
+ });
504
+ } else if (stats.size < currentSize) {
505
+ // File was rotated or truncated!
506
+ currentSize = 0;
507
+ }
508
+ } catch {
509
+ // Non-fatal read error
510
+ }
511
+ };
512
+
513
+ // Poll every 300ms for responsive terminal updates
514
+ const interval = setInterval(checkNewLines, 300);
515
+
516
+ // Clean exit on SIGINT (Ctrl+C)
517
+ return new Promise((resolve) => {
518
+ const cleanup = () => {
519
+ clearInterval(interval);
520
+ output.log(`\n${c.cyan('👋 Exiting Z-Shield live monitor.')}`);
521
+ resolve(0);
522
+ };
523
+
524
+ process.once('SIGINT', cleanup);
525
+ process.once('SIGTERM', cleanup);
526
+ });
527
+ }
528
+
529
+ case 'block': {
530
+ const ip = args[0];
531
+ if (!ip) {
532
+ output.error(c.bold(c.red('[ERROR] Missing IP address. Usage: npx z-shield block <ip> [--duration 10m] [--reason <text>]')));
533
+ return 1;
534
+ }
535
+
536
+ if (!isValidIp(ip)) {
537
+ output.error(c.bold(c.red(`[ERROR] Invalid IP address "${ip}". Must be a valid IPv4 or IPv6 address.`)));
538
+ return 1;
539
+ }
540
+
541
+ const durationRaw = options.duration || '15m';
542
+ let durationMs;
543
+ try {
544
+ durationMs = parseDuration(durationRaw);
545
+ } catch (err) {
546
+ output.error(c.bold(c.red(`[ERROR] ${err.message}`)));
547
+ return 1;
548
+ }
549
+
550
+ const reason = options.reason || 'Manual block requested via CLI';
551
+
552
+ output.log(c.cyan('\n\n===================================================='));
553
+ output.log(c.bold(c.cyan('🛡️ Z-Shield CLI: Block Request')));
554
+ output.log(c.cyan('===================================================='));
555
+ output.log(`${c.bold('Target IP:')} ${c.bold(c.cyan(ip))}`);
556
+ output.log(`${c.bold('Duration:')} ${c.yellow(durationRaw)} (${durationMs.toLocaleString()}ms)`);
557
+ output.log(`${c.bold('Reason:')} ${reason}`);
558
+ output.log(`\n${c.bold(c.yellow('ℹ Process-Local Architecture Notice:'))}`);
559
+ output.log(c.dim('Z-Shield manages active temporary blocks in server memory (RAM).'));
560
+ output.log(c.dim('Because this CLI runs in an independent Node.js process, it cannot'));
561
+ output.log(c.dim("directly modify the running Express server's memory heap without an"));
562
+ output.log(c.dim('external IPC socket or database (which Z-Shield intentionally avoids).'));
563
+ output.log(`\n${c.bold('To block this IP in your running Express application:')}`);
564
+ output.log(c.green(` shield.block('${ip}', '${durationRaw}', '${reason}');`));
565
+
566
+ // Record an audit log event
567
+ try {
568
+ fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
569
+ const auditEvent = {
570
+ timestamp: new Date().toISOString(),
571
+ appName: 'cli',
572
+ type: 'cli_block_requested',
573
+ severity: 'info',
574
+ ip,
575
+ method: 'CLI',
576
+ route: '/cli/block',
577
+ reason: `Requested duration: ${durationRaw} | Reason: ${reason}`,
578
+ action: 'requested'
579
+ };
580
+ fs.appendFileSync(logFilePath, JSON.stringify(auditEvent) + '\n');
581
+ output.log(`\n${c.green('✔')} Audit entry recorded in: ${c.gray(logFilePath)}`);
582
+ } catch {
583
+ // Safe fallback
584
+ }
585
+
586
+ output.log(c.cyan('===================================================='));
587
+ return 0;
588
+ }
589
+
590
+ case 'unblock': {
591
+ const ip = args[0];
592
+ if (!ip) {
593
+ output.error(c.bold(c.red('[ERROR] Missing IP address. Usage: npx z-shield unblock <ip>')));
594
+ return 1;
595
+ }
596
+
597
+ if (!isValidIp(ip)) {
598
+ output.error(c.bold(c.red(`[ERROR] Invalid IP address "${ip}". Must be a valid IPv4 or IPv6 address.`)));
599
+ return 1;
600
+ }
601
+
602
+ output.log(c.cyan('\n\n===================================================='));
603
+ output.log(c.bold(c.cyan('🛡️ Z-Shield CLI: Unblock Request')));
604
+ output.log(c.cyan('===================================================='));
605
+ output.log(`${c.bold('Target IP:')} ${c.bold(c.cyan(ip))}`);
606
+ output.log(`\n${c.bold(c.yellow('ℹ Process-Local Architecture Notice:'))}`);
607
+ output.log(c.dim('Z-Shield manages active temporary blocks in server memory (RAM).'));
608
+ output.log(`\n${c.bold('To unblock this IP in your running Express application:')}`);
609
+ output.log(c.green(` shield.unblock('${ip}');`));
610
+
611
+ // Record an audit log event
612
+ try {
613
+ fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
614
+ const auditEvent = {
615
+ timestamp: new Date().toISOString(),
616
+ appName: 'cli',
617
+ type: 'cli_unblock_requested',
618
+ severity: 'info',
619
+ ip,
620
+ method: 'CLI',
621
+ route: '/cli/unblock',
622
+ reason: 'Manual unblock requested via CLI',
623
+ action: 'requested'
624
+ };
625
+ fs.appendFileSync(logFilePath, JSON.stringify(auditEvent) + '\n');
626
+ output.log(`\n${c.green('✔')} Audit entry recorded in: ${c.gray(logFilePath)}`);
627
+ } catch {
628
+ // Safe fallback
629
+ }
630
+
631
+ output.log(c.cyan('===================================================='));
632
+ return 0;
633
+ }
634
+
635
+ case 'help':
636
+ default: {
637
+ output.log(c.cyan('\n\n===================================================='));
638
+ output.log(c.bold(c.cyan('🛡️ Z-Shield CLI — API Abuse Detection & Protection')));
639
+ output.log(c.cyan('===================================================='));
640
+ output.log(c.bold('Usage:'));
641
+ output.log(` ${c.bold('npx z-shield')} ${c.cyan('<command>')} [options]`);
642
+ output.log(`\n${c.bold('Commands:')}`);
643
+ output.log(` ${c.bold('npx z-shield status')} Show security overview and top offender IPs`);
644
+ output.log(` ${c.bold('npx z-shield logs')} [--limit 50] Display recent security event logs`);
645
+ output.log(` ${c.bold('npx z-shield monitor')} Stream security events in real time`);
646
+ output.log(` ${c.bold('npx z-shield block')} <ip> Instructions & audit for blocking an IP`);
647
+ output.log(` ${c.bold('npx z-shield unblock')} <ip> Instructions & audit for unblocking an IP`);
648
+ output.log(`\n${c.bold('Options:')}`);
649
+ output.log(` ${c.yellow('--limit')} <number> Number of log entries to display (default: 20)`);
650
+ output.log(` ${c.yellow('--duration')} <time> Block duration (e.g. 10m, 1h, 30s)`);
651
+ output.log(` ${c.yellow('--reason')} <text> Reason for manual block`);
652
+ output.log(` ${c.yellow('--log-dir')} <path> Path to log directory (default: .z-shield-logs)`);
653
+ output.log(` ${c.yellow('--color')} Force enable colored terminal output`);
654
+ output.log(` ${c.yellow('--no-color')} Disable colored terminal output`);
655
+ output.log(` ${c.yellow('--help, -h')} Display this help message`);
656
+ output.log(c.cyan('===================================================='));
657
+ return command === 'help' ? 0 : 1;
658
+ }
659
+ }
660
+ }
661
+
662
+ // Execute directly if run from CLI
663
+ if (require.main === module) {
664
+ runCommand(process.argv.slice(2)).then(code => {
665
+ if (code !== 0) {
666
+ process.exit(code);
667
+ }
668
+ });
669
+ }
670
+
671
+ module.exports = {
672
+ createColors,
673
+ resolveColorSupport,
674
+ parseCliArgs,
675
+ formatLogEvent,
676
+ readLogs,
677
+ calculateStatus,
678
+ resolveLogPath,
679
+ runCommand
680
+ };