z-shield 0.1.0 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "z-shield",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "API abuse detection, rate limiting, and temporary IP blocking middleware for Node.js and Express",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -6,8 +6,8 @@ const path = require('node:path');
6
6
  const net = require('node:net');
7
7
  const readline = require('node:readline');
8
8
 
9
- const { stripAnsi, sanitizeString } = require('./logger');
10
- const { parseDuration, isValidIp } = require('./block-manager');
9
+ const { stripAnsi, sanitizeString, sanitizeRoute, sanitizeMethod } = require('./logger');
10
+ const { parseDuration, isValidIp, normalizeIp } = require('./block-manager');
11
11
 
12
12
  /**
13
13
  * Parses raw command-line arguments into a structured command object.
@@ -284,6 +284,83 @@ function readLogs(logFilePath, limit = 20) {
284
284
  };
285
285
  }
286
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
+
287
364
  /**
288
365
  * Computes security summary statistics from the log file.
289
366
  *
@@ -297,16 +374,16 @@ function calculateStatus(logFilePath) {
297
374
 
298
375
  const { events: allEvents, malformedCount } = readLogs(logFilePath, 100000);
299
376
 
300
- const ipCounts = {};
301
- const routeCounts = {};
302
- const typeCounts = {};
303
- const actionCounts = {};
377
+ const ipCounts = Object.create(null);
378
+ const routeCounts = Object.create(null);
379
+ const typeCounts = Object.create(null);
380
+ const actionCounts = Object.create(null);
304
381
 
305
382
  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';
383
+ const ip = sanitizeIp(event.ip);
384
+ const route = sanitizeRoute(event.route);
385
+ const type = sanitizeType(event.type);
386
+ const action = sanitizeAction(event.action);
310
387
 
311
388
  ipCounts[ip] = (ipCounts[ip] || 0) + 1;
312
389
  routeCounts[route] = (routeCounts[route] || 0) + 1;
@@ -372,7 +449,8 @@ async function runCommand(argv = process.argv.slice(2), output = console) {
372
449
 
373
450
  output.log(`\n${c.bold('Actions Breakdown:')}`);
374
451
  for (const [action, count] of Object.entries(status.actionCounts)) {
375
- const upper = action.toUpperCase();
452
+ const cleanAction = sanitizeAction(action);
453
+ const upper = cleanAction.toUpperCase();
376
454
  let styledAction = upper;
377
455
  if (upper === 'BLOCKED') styledAction = c.bold(c.red(upper));
378
456
  else if (upper === 'WARNED') styledAction = c.bold(c.yellow(upper));
@@ -384,20 +462,23 @@ async function runCommand(argv = process.argv.slice(2), output = console) {
384
462
 
385
463
  output.log(`\n${c.bold('Event Types:')}`);
386
464
  for (const [type, count] of Object.entries(status.typeCounts)) {
387
- output.log(` - ${c.yellow(type)}: ${c.bold(count)}`);
465
+ const cleanType = sanitizeType(type);
466
+ output.log(` - ${c.yellow(cleanType)}: ${c.bold(count)}`);
388
467
  }
389
468
 
390
469
  if (status.topIps.length > 0) {
391
470
  output.log(`\n${c.bold('Top Offending IPs:')}`);
392
471
  for (const item of status.topIps) {
393
- output.log(` - ${c.cyan(item.ip)}: ${c.bold(item.count)} events`);
472
+ const cleanIp = sanitizeIp(item.ip);
473
+ output.log(` - ${c.cyan(cleanIp)}: ${c.bold(item.count)} events`);
394
474
  }
395
475
  }
396
476
 
397
477
  if (status.topRoutes.length > 0) {
398
478
  output.log(`\n${c.bold('Top Targeted Routes:')}`);
399
479
  for (const item of status.topRoutes) {
400
- output.log(` - ${c.underline(item.route)}: ${c.bold(item.count)} events`);
480
+ const cleanRoute = sanitizeRoute(item.route);
481
+ output.log(` - ${c.underline(cleanRoute)}: ${c.bold(item.count)} events`);
401
482
  }
402
483
  }
403
484
 
@@ -676,5 +757,8 @@ module.exports = {
676
757
  readLogs,
677
758
  calculateStatus,
678
759
  resolveLogPath,
679
- runCommand
760
+ runCommand,
761
+ sanitizeIp,
762
+ sanitizeType,
763
+ sanitizeAction
680
764
  };
package/src/logger.js CHANGED
@@ -46,11 +46,12 @@ function sanitizeRoute(route, maxLength = 500) {
46
46
  if (!route || typeof route !== 'string') {
47
47
  return '/';
48
48
  }
49
- let clean = stripAnsi(route);
49
+ let clean = route.split(/[\r\n]/)[0];
50
+ clean = stripAnsi(clean);
50
51
  // Strip query parameters and URL fragments to prevent credential leakage (CWE-532)
51
52
  clean = clean.split('?')[0].split('#')[0];
52
53
  // Remove dangerous control characters and newlines
53
- clean = clean.replace(/[\x00-\x1f\x7f]/g, '');
54
+ clean = clean.replace(/[\x00-\x1f\x7f]/g, '').trim();
54
55
  if (!clean.startsWith('/')) {
55
56
  clean = '/' + clean;
56
57
  }