z-shield 0.1.1 → 0.1.2
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/README.md +123 -6
- package/dist/block-manager.d.ts +134 -0
- package/dist/block-manager.d.ts.map +1 -0
- package/dist/block-manager.js +425 -0
- package/dist/block-manager.js.map +1 -0
- package/dist/cli.d.ts +141 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +784 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +148 -0
- package/dist/config.js.map +1 -0
- package/dist/detector.d.ts +56 -0
- package/dist/detector.d.ts.map +1 -0
- package/dist/detector.js +81 -0
- package/dist/detector.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +141 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +347 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware.d.ts +68 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +272 -0
- package/dist/middleware.js.map +1 -0
- package/dist/next.d.ts +73 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +486 -0
- package/dist/next.js.map +1 -0
- package/dist/rate-limiter.d.ts +75 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +153 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/next.d.ts +1 -0
- package/next.js +2 -0
- package/package.json +34 -5
- package/src/block-manager.js +0 -326
- package/src/cli.js +0 -764
- package/src/config.js +0 -165
- package/src/detector.js +0 -88
- package/src/index.js +0 -97
- package/src/logger.js +0 -280
- package/src/middleware.js +0 -288
- package/src/rate-limiter.js +0 -186
package/src/cli.js
DELETED
|
@@ -1,764 +0,0 @@
|
|
|
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, sanitizeRoute, sanitizeMethod } = require('./logger');
|
|
10
|
-
const { parseDuration, isValidIp, normalizeIp } = 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
|
-
* Sanitizes untrusted IP address values from logs for safe status aggregation and output.
|
|
289
|
-
* Strips ANSI escapes, truncates at newlines/control characters, and extracts valid IP addresses.
|
|
290
|
-
*
|
|
291
|
-
* @param {*} rawIp
|
|
292
|
-
* @returns {string} Normalized IP address or 'unknown'
|
|
293
|
-
*/
|
|
294
|
-
function sanitizeIp(rawIp) {
|
|
295
|
-
if (!rawIp || typeof rawIp !== 'string') return 'unknown';
|
|
296
|
-
const trimmed = rawIp.trim();
|
|
297
|
-
if (trimmed.length === 0) return 'unknown';
|
|
298
|
-
|
|
299
|
-
if (net.isIP(trimmed) !== 0) {
|
|
300
|
-
return normalizeIp(trimmed);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const stripped = stripAnsi(trimmed);
|
|
304
|
-
if (net.isIP(stripped) !== 0) {
|
|
305
|
-
return normalizeIp(stripped);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const beforeEscape = trimmed.split(/[\x1b\r\n\x00-\x1f\x7f]/)[0].trim();
|
|
309
|
-
if (beforeEscape.length > 0 && net.isIP(beforeEscape) !== 0) {
|
|
310
|
-
return normalizeIp(beforeEscape);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const ipv4Match = stripped.match(/(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/);
|
|
314
|
-
if (ipv4Match && net.isIP(ipv4Match[0]) === 4) {
|
|
315
|
-
return normalizeIp(ipv4Match[0]);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const ipv6Candidates = stripped.match(/[0-9a-fA-F:]+/g);
|
|
319
|
-
if (ipv6Candidates) {
|
|
320
|
-
for (const cand of ipv6Candidates) {
|
|
321
|
-
if (net.isIP(cand) === 6) {
|
|
322
|
-
return normalizeIp(cand);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return 'unknown';
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Sanitizes untrusted event type strings from logs.
|
|
332
|
-
*
|
|
333
|
-
* @param {*} rawType
|
|
334
|
-
* @param {number} [maxLength=30]
|
|
335
|
-
* @returns {string} Clean event type string
|
|
336
|
-
*/
|
|
337
|
-
function sanitizeType(rawType, maxLength = 30) {
|
|
338
|
-
if (!rawType || typeof rawType !== 'string') return 'unknown';
|
|
339
|
-
let clean = rawType.split(/[\r\n\x1b]/)[0];
|
|
340
|
-
clean = stripAnsi(clean);
|
|
341
|
-
clean = clean.replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
|
342
|
-
clean = clean.replace(/\s+/g, ' ');
|
|
343
|
-
return clean.slice(0, maxLength) || 'unknown';
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Sanitizes untrusted action strings from logs.
|
|
348
|
-
*
|
|
349
|
-
* @param {*} rawAction
|
|
350
|
-
* @param {number} [maxLength=20]
|
|
351
|
-
* @returns {string} Clean action string
|
|
352
|
-
*/
|
|
353
|
-
function sanitizeAction(rawAction, maxLength = 20) {
|
|
354
|
-
if (!rawAction || typeof rawAction !== 'string') return 'unknown';
|
|
355
|
-
let clean = rawAction.split(/[\r\n\x1b]/)[0];
|
|
356
|
-
clean = stripAnsi(clean);
|
|
357
|
-
clean = clean.replace(/[\x00-\x1f\x7f]/g, '').trim().toLowerCase();
|
|
358
|
-
if (clean === 'warn') clean = 'warned';
|
|
359
|
-
else if (clean === 'block') clean = 'blocked';
|
|
360
|
-
else if (clean === 'allow') clean = 'allowed';
|
|
361
|
-
return clean.slice(0, maxLength) || 'unknown';
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Computes security summary statistics from the log file.
|
|
366
|
-
*
|
|
367
|
-
* @param {string} logFilePath
|
|
368
|
-
* @returns {Object}
|
|
369
|
-
*/
|
|
370
|
-
function calculateStatus(logFilePath) {
|
|
371
|
-
if (!fs.existsSync(logFilePath)) {
|
|
372
|
-
return { fileExists: false, totalEvents: 0 };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const { events: allEvents, malformedCount } = readLogs(logFilePath, 100000);
|
|
376
|
-
|
|
377
|
-
const ipCounts = Object.create(null);
|
|
378
|
-
const routeCounts = Object.create(null);
|
|
379
|
-
const typeCounts = Object.create(null);
|
|
380
|
-
const actionCounts = Object.create(null);
|
|
381
|
-
|
|
382
|
-
for (const event of allEvents) {
|
|
383
|
-
const ip = sanitizeIp(event.ip);
|
|
384
|
-
const route = sanitizeRoute(event.route);
|
|
385
|
-
const type = sanitizeType(event.type);
|
|
386
|
-
const action = sanitizeAction(event.action);
|
|
387
|
-
|
|
388
|
-
ipCounts[ip] = (ipCounts[ip] || 0) + 1;
|
|
389
|
-
routeCounts[route] = (routeCounts[route] || 0) + 1;
|
|
390
|
-
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
|
391
|
-
actionCounts[action] = (actionCounts[action] || 0) + 1;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Sort top offenders
|
|
395
|
-
const topIps = Object.entries(ipCounts)
|
|
396
|
-
.sort((a, b) => b[1] - a[1])
|
|
397
|
-
.slice(0, 5)
|
|
398
|
-
.map(([ip, count]) => ({ ip, count }));
|
|
399
|
-
|
|
400
|
-
const topRoutes = Object.entries(routeCounts)
|
|
401
|
-
.sort((a, b) => b[1] - a[1])
|
|
402
|
-
.slice(0, 5)
|
|
403
|
-
.map(([route, count]) => ({ route, count }));
|
|
404
|
-
|
|
405
|
-
return {
|
|
406
|
-
fileExists: true,
|
|
407
|
-
totalEvents: allEvents.length,
|
|
408
|
-
malformedCount,
|
|
409
|
-
typeCounts,
|
|
410
|
-
actionCounts,
|
|
411
|
-
topIps,
|
|
412
|
-
topRoutes
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Main command runner. Returns an exit code (0 for success, 1 for error).
|
|
418
|
-
*
|
|
419
|
-
* @param {string[]} argv
|
|
420
|
-
* @param {Console|Object} [output=console]
|
|
421
|
-
* @returns {Promise<number>} Exit code
|
|
422
|
-
*/
|
|
423
|
-
async function runCommand(argv = process.argv.slice(2), output = console) {
|
|
424
|
-
const { command, args, options } = parseCliArgs(argv);
|
|
425
|
-
const logFilePath = resolveLogPath(options);
|
|
426
|
-
const useColors = resolveColorSupport(options, output);
|
|
427
|
-
const c = createColors(useColors);
|
|
428
|
-
|
|
429
|
-
switch (command) {
|
|
430
|
-
case 'status': {
|
|
431
|
-
output.log(c.cyan('\n\n===================================================='));
|
|
432
|
-
output.log(c.bold(c.cyan('🛡️ Z-Shield Security Status')));
|
|
433
|
-
output.log(c.cyan('===================================================='));
|
|
434
|
-
output.log(`${c.bold('Log File:')} ${c.gray(logFilePath)}`);
|
|
435
|
-
|
|
436
|
-
const status = calculateStatus(logFilePath);
|
|
437
|
-
|
|
438
|
-
if (!status.fileExists) {
|
|
439
|
-
output.log(`${c.bold('Status:')} ${c.yellow('No log file found yet.')}`);
|
|
440
|
-
output.log(c.dim('ℹ Start your Express server to begin logging security events.'));
|
|
441
|
-
output.log(c.cyan('====================================================\n'));
|
|
442
|
-
return 0;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
output.log(`${c.bold('Total Events Recorded:')} ${c.bold(c.cyan(status.totalEvents))}`);
|
|
446
|
-
if (status.malformedCount > 0) {
|
|
447
|
-
output.log(`${c.bold(c.yellow('⚠️ Malformed Lines Skipped:'))} ${c.yellow(status.malformedCount)}`);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
output.log(`\n${c.bold('Actions Breakdown:')}`);
|
|
451
|
-
for (const [action, count] of Object.entries(status.actionCounts)) {
|
|
452
|
-
const cleanAction = sanitizeAction(action);
|
|
453
|
-
const upper = cleanAction.toUpperCase();
|
|
454
|
-
let styledAction = upper;
|
|
455
|
-
if (upper === 'BLOCKED') styledAction = c.bold(c.red(upper));
|
|
456
|
-
else if (upper === 'WARNED') styledAction = c.bold(c.yellow(upper));
|
|
457
|
-
else if (upper === 'ALLOWED') styledAction = c.bold(c.green(upper));
|
|
458
|
-
else if (upper === 'UNBLOCKED') styledAction = c.bold(c.cyan(upper));
|
|
459
|
-
else if (upper === 'REQUESTED') styledAction = c.bold(c.blue(upper));
|
|
460
|
-
output.log(` - ${styledAction}: ${c.bold(count)}`);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
output.log(`\n${c.bold('Event Types:')}`);
|
|
464
|
-
for (const [type, count] of Object.entries(status.typeCounts)) {
|
|
465
|
-
const cleanType = sanitizeType(type);
|
|
466
|
-
output.log(` - ${c.yellow(cleanType)}: ${c.bold(count)}`);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (status.topIps.length > 0) {
|
|
470
|
-
output.log(`\n${c.bold('Top Offending IPs:')}`);
|
|
471
|
-
for (const item of status.topIps) {
|
|
472
|
-
const cleanIp = sanitizeIp(item.ip);
|
|
473
|
-
output.log(` - ${c.cyan(cleanIp)}: ${c.bold(item.count)} events`);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
if (status.topRoutes.length > 0) {
|
|
478
|
-
output.log(`\n${c.bold('Top Targeted Routes:')}`);
|
|
479
|
-
for (const item of status.topRoutes) {
|
|
480
|
-
const cleanRoute = sanitizeRoute(item.route);
|
|
481
|
-
output.log(` - ${c.underline(cleanRoute)}: ${c.bold(item.count)} events`);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
output.log(`\n${c.dim('ℹ Process-Local Architecture Notice:')}`);
|
|
486
|
-
output.log(c.dim('Statistics reflect historical events recorded in the local log file.'));
|
|
487
|
-
output.log(c.dim('Active in-memory counters and temporary blocks reside in server RAM'));
|
|
488
|
-
output.log(c.dim('and are managed inside the running Express process.'));
|
|
489
|
-
|
|
490
|
-
output.log(c.cyan('===================================================='));
|
|
491
|
-
return 0;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
case 'logs': {
|
|
495
|
-
let limit = 20;
|
|
496
|
-
if (options.limit) {
|
|
497
|
-
const parsed = parseInt(options.limit, 10);
|
|
498
|
-
if (isNaN(parsed) || parsed <= 0) {
|
|
499
|
-
output.error(c.bold(c.red('[ERROR] --limit must be a positive integer.')));
|
|
500
|
-
return 1;
|
|
501
|
-
}
|
|
502
|
-
limit = parsed;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const { events, totalLines, malformedCount, fileExists } = readLogs(logFilePath, limit);
|
|
506
|
-
|
|
507
|
-
output.log(c.cyan('\n\n===================================================='));
|
|
508
|
-
output.log(c.bold(c.cyan(`🛡️ Z-Shield Security Logs (Showing last ${events.length} of ${totalLines} events)`)));
|
|
509
|
-
output.log(`${c.bold('Source:')} ${c.gray(logFilePath)}`);
|
|
510
|
-
output.log(c.cyan('===================================================='));
|
|
511
|
-
|
|
512
|
-
if (!fileExists || events.length === 0) {
|
|
513
|
-
output.log(c.dim('ℹ No security events found in log file.'));
|
|
514
|
-
output.log(c.cyan('===================================================='));
|
|
515
|
-
return 0;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
for (const event of events) {
|
|
519
|
-
output.log(formatLogEvent(event, { colors: useColors }));
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (malformedCount > 0) {
|
|
523
|
-
output.log(`\n${c.yellow(`⚠️ Notice: Skipped ${malformedCount} unparseable or corrupted lines.`)}`);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
output.log(c.cyan('===================================================='));
|
|
527
|
-
return 0;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
case 'monitor': {
|
|
531
|
-
output.log(c.cyan('\n\n===================================================='));
|
|
532
|
-
output.log(c.bold(c.cyan('🛡️ Z-Shield Live Security Event Monitor')));
|
|
533
|
-
output.log(`${c.bold('Watching:')} ${c.gray(logFilePath)}`);
|
|
534
|
-
output.log(c.dim('Press Ctrl+C to exit.'));
|
|
535
|
-
output.log(c.cyan('===================================================='));
|
|
536
|
-
|
|
537
|
-
let currentSize = 0;
|
|
538
|
-
if (fs.existsSync(logFilePath)) {
|
|
539
|
-
currentSize = fs.statSync(logFilePath).size;
|
|
540
|
-
// Show recent events for immediate context
|
|
541
|
-
const { events } = readLogs(logFilePath, 5);
|
|
542
|
-
if (events.length > 0) {
|
|
543
|
-
output.log(c.dim('--- Recent Events ---'));
|
|
544
|
-
for (const ev of events) {
|
|
545
|
-
output.log(formatLogEvent(ev, { colors: useColors }));
|
|
546
|
-
}
|
|
547
|
-
output.log(c.dim('--- Live Stream Active ---'));
|
|
548
|
-
}
|
|
549
|
-
} else {
|
|
550
|
-
output.log(c.dim('ℹ Waiting for server to create log file...'));
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Live watcher polling function
|
|
554
|
-
const checkNewLines = () => {
|
|
555
|
-
if (!fs.existsSync(logFilePath)) {
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
try {
|
|
559
|
-
const stats = fs.statSync(logFilePath);
|
|
560
|
-
if (stats.size > currentSize) {
|
|
561
|
-
const stream = fs.createReadStream(logFilePath, {
|
|
562
|
-
start: currentSize,
|
|
563
|
-
end: stats.size,
|
|
564
|
-
encoding: 'utf8'
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
currentSize = stats.size;
|
|
568
|
-
|
|
569
|
-
let buffer = '';
|
|
570
|
-
stream.on('data', chunk => {
|
|
571
|
-
buffer += chunk;
|
|
572
|
-
const lines = buffer.split('\n');
|
|
573
|
-
buffer = lines.pop(); // keep last partial line
|
|
574
|
-
for (const line of lines) {
|
|
575
|
-
if (line.trim().length > 0) {
|
|
576
|
-
try {
|
|
577
|
-
const parsed = JSON.parse(line);
|
|
578
|
-
output.log(formatLogEvent(parsed, { colors: useColors }));
|
|
579
|
-
} catch {
|
|
580
|
-
output.log(c.yellow('[⚠️ Corrupted log record]'));
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
} else if (stats.size < currentSize) {
|
|
586
|
-
// File was rotated or truncated!
|
|
587
|
-
currentSize = 0;
|
|
588
|
-
}
|
|
589
|
-
} catch {
|
|
590
|
-
// Non-fatal read error
|
|
591
|
-
}
|
|
592
|
-
};
|
|
593
|
-
|
|
594
|
-
// Poll every 300ms for responsive terminal updates
|
|
595
|
-
const interval = setInterval(checkNewLines, 300);
|
|
596
|
-
|
|
597
|
-
// Clean exit on SIGINT (Ctrl+C)
|
|
598
|
-
return new Promise((resolve) => {
|
|
599
|
-
const cleanup = () => {
|
|
600
|
-
clearInterval(interval);
|
|
601
|
-
output.log(`\n${c.cyan('👋 Exiting Z-Shield live monitor.')}`);
|
|
602
|
-
resolve(0);
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
process.once('SIGINT', cleanup);
|
|
606
|
-
process.once('SIGTERM', cleanup);
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
case 'block': {
|
|
611
|
-
const ip = args[0];
|
|
612
|
-
if (!ip) {
|
|
613
|
-
output.error(c.bold(c.red('[ERROR] Missing IP address. Usage: npx z-shield block <ip> [--duration 10m] [--reason <text>]')));
|
|
614
|
-
return 1;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
if (!isValidIp(ip)) {
|
|
618
|
-
output.error(c.bold(c.red(`[ERROR] Invalid IP address "${ip}". Must be a valid IPv4 or IPv6 address.`)));
|
|
619
|
-
return 1;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
const durationRaw = options.duration || '15m';
|
|
623
|
-
let durationMs;
|
|
624
|
-
try {
|
|
625
|
-
durationMs = parseDuration(durationRaw);
|
|
626
|
-
} catch (err) {
|
|
627
|
-
output.error(c.bold(c.red(`[ERROR] ${err.message}`)));
|
|
628
|
-
return 1;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
const reason = options.reason || 'Manual block requested via CLI';
|
|
632
|
-
|
|
633
|
-
output.log(c.cyan('\n\n===================================================='));
|
|
634
|
-
output.log(c.bold(c.cyan('🛡️ Z-Shield CLI: Block Request')));
|
|
635
|
-
output.log(c.cyan('===================================================='));
|
|
636
|
-
output.log(`${c.bold('Target IP:')} ${c.bold(c.cyan(ip))}`);
|
|
637
|
-
output.log(`${c.bold('Duration:')} ${c.yellow(durationRaw)} (${durationMs.toLocaleString()}ms)`);
|
|
638
|
-
output.log(`${c.bold('Reason:')} ${reason}`);
|
|
639
|
-
output.log(`\n${c.bold(c.yellow('ℹ Process-Local Architecture Notice:'))}`);
|
|
640
|
-
output.log(c.dim('Z-Shield manages active temporary blocks in server memory (RAM).'));
|
|
641
|
-
output.log(c.dim('Because this CLI runs in an independent Node.js process, it cannot'));
|
|
642
|
-
output.log(c.dim("directly modify the running Express server's memory heap without an"));
|
|
643
|
-
output.log(c.dim('external IPC socket or database (which Z-Shield intentionally avoids).'));
|
|
644
|
-
output.log(`\n${c.bold('To block this IP in your running Express application:')}`);
|
|
645
|
-
output.log(c.green(` shield.block('${ip}', '${durationRaw}', '${reason}');`));
|
|
646
|
-
|
|
647
|
-
// Record an audit log event
|
|
648
|
-
try {
|
|
649
|
-
fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
|
|
650
|
-
const auditEvent = {
|
|
651
|
-
timestamp: new Date().toISOString(),
|
|
652
|
-
appName: 'cli',
|
|
653
|
-
type: 'cli_block_requested',
|
|
654
|
-
severity: 'info',
|
|
655
|
-
ip,
|
|
656
|
-
method: 'CLI',
|
|
657
|
-
route: '/cli/block',
|
|
658
|
-
reason: `Requested duration: ${durationRaw} | Reason: ${reason}`,
|
|
659
|
-
action: 'requested'
|
|
660
|
-
};
|
|
661
|
-
fs.appendFileSync(logFilePath, JSON.stringify(auditEvent) + '\n');
|
|
662
|
-
output.log(`\n${c.green('✔')} Audit entry recorded in: ${c.gray(logFilePath)}`);
|
|
663
|
-
} catch {
|
|
664
|
-
// Safe fallback
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
output.log(c.cyan('===================================================='));
|
|
668
|
-
return 0;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
case 'unblock': {
|
|
672
|
-
const ip = args[0];
|
|
673
|
-
if (!ip) {
|
|
674
|
-
output.error(c.bold(c.red('[ERROR] Missing IP address. Usage: npx z-shield unblock <ip>')));
|
|
675
|
-
return 1;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (!isValidIp(ip)) {
|
|
679
|
-
output.error(c.bold(c.red(`[ERROR] Invalid IP address "${ip}". Must be a valid IPv4 or IPv6 address.`)));
|
|
680
|
-
return 1;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
output.log(c.cyan('\n\n===================================================='));
|
|
684
|
-
output.log(c.bold(c.cyan('🛡️ Z-Shield CLI: Unblock Request')));
|
|
685
|
-
output.log(c.cyan('===================================================='));
|
|
686
|
-
output.log(`${c.bold('Target IP:')} ${c.bold(c.cyan(ip))}`);
|
|
687
|
-
output.log(`\n${c.bold(c.yellow('ℹ Process-Local Architecture Notice:'))}`);
|
|
688
|
-
output.log(c.dim('Z-Shield manages active temporary blocks in server memory (RAM).'));
|
|
689
|
-
output.log(`\n${c.bold('To unblock this IP in your running Express application:')}`);
|
|
690
|
-
output.log(c.green(` shield.unblock('${ip}');`));
|
|
691
|
-
|
|
692
|
-
// Record an audit log event
|
|
693
|
-
try {
|
|
694
|
-
fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
|
|
695
|
-
const auditEvent = {
|
|
696
|
-
timestamp: new Date().toISOString(),
|
|
697
|
-
appName: 'cli',
|
|
698
|
-
type: 'cli_unblock_requested',
|
|
699
|
-
severity: 'info',
|
|
700
|
-
ip,
|
|
701
|
-
method: 'CLI',
|
|
702
|
-
route: '/cli/unblock',
|
|
703
|
-
reason: 'Manual unblock requested via CLI',
|
|
704
|
-
action: 'requested'
|
|
705
|
-
};
|
|
706
|
-
fs.appendFileSync(logFilePath, JSON.stringify(auditEvent) + '\n');
|
|
707
|
-
output.log(`\n${c.green('✔')} Audit entry recorded in: ${c.gray(logFilePath)}`);
|
|
708
|
-
} catch {
|
|
709
|
-
// Safe fallback
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
output.log(c.cyan('===================================================='));
|
|
713
|
-
return 0;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
case 'help':
|
|
717
|
-
default: {
|
|
718
|
-
output.log(c.cyan('\n\n===================================================='));
|
|
719
|
-
output.log(c.bold(c.cyan('🛡️ Z-Shield CLI — API Abuse Detection & Protection')));
|
|
720
|
-
output.log(c.cyan('===================================================='));
|
|
721
|
-
output.log(c.bold('Usage:'));
|
|
722
|
-
output.log(` ${c.bold('npx z-shield')} ${c.cyan('<command>')} [options]`);
|
|
723
|
-
output.log(`\n${c.bold('Commands:')}`);
|
|
724
|
-
output.log(` ${c.bold('npx z-shield status')} Show security overview and top offender IPs`);
|
|
725
|
-
output.log(` ${c.bold('npx z-shield logs')} [--limit 50] Display recent security event logs`);
|
|
726
|
-
output.log(` ${c.bold('npx z-shield monitor')} Stream security events in real time`);
|
|
727
|
-
output.log(` ${c.bold('npx z-shield block')} <ip> Instructions & audit for blocking an IP`);
|
|
728
|
-
output.log(` ${c.bold('npx z-shield unblock')} <ip> Instructions & audit for unblocking an IP`);
|
|
729
|
-
output.log(`\n${c.bold('Options:')}`);
|
|
730
|
-
output.log(` ${c.yellow('--limit')} <number> Number of log entries to display (default: 20)`);
|
|
731
|
-
output.log(` ${c.yellow('--duration')} <time> Block duration (e.g. 10m, 1h, 30s)`);
|
|
732
|
-
output.log(` ${c.yellow('--reason')} <text> Reason for manual block`);
|
|
733
|
-
output.log(` ${c.yellow('--log-dir')} <path> Path to log directory (default: .z-shield-logs)`);
|
|
734
|
-
output.log(` ${c.yellow('--color')} Force enable colored terminal output`);
|
|
735
|
-
output.log(` ${c.yellow('--no-color')} Disable colored terminal output`);
|
|
736
|
-
output.log(` ${c.yellow('--help, -h')} Display this help message`);
|
|
737
|
-
output.log(c.cyan('===================================================='));
|
|
738
|
-
return command === 'help' ? 0 : 1;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Execute directly if run from CLI
|
|
744
|
-
if (require.main === module) {
|
|
745
|
-
runCommand(process.argv.slice(2)).then(code => {
|
|
746
|
-
if (code !== 0) {
|
|
747
|
-
process.exit(code);
|
|
748
|
-
}
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
module.exports = {
|
|
753
|
-
createColors,
|
|
754
|
-
resolveColorSupport,
|
|
755
|
-
parseCliArgs,
|
|
756
|
-
formatLogEvent,
|
|
757
|
-
readLogs,
|
|
758
|
-
calculateStatus,
|
|
759
|
-
resolveLogPath,
|
|
760
|
-
runCommand,
|
|
761
|
-
sanitizeIp,
|
|
762
|
-
sanitizeType,
|
|
763
|
-
sanitizeAction
|
|
764
|
-
};
|