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/config.js
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const path = require('node:path');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Default configuration values for Z-Shield.
|
|
7
|
-
* Designed with conservative, safe defaults.
|
|
8
|
-
*/
|
|
9
|
-
const DEFAULT_CONFIG = {
|
|
10
|
-
// Unique identifier for the host application (useful when multiple apps share a server)
|
|
11
|
-
appName: 'default-app',
|
|
12
|
-
|
|
13
|
-
// Mode: 'monitor' (detect and log only) or 'protect' (enforce 429 and temporary blocks)
|
|
14
|
-
mode: 'monitor',
|
|
15
|
-
|
|
16
|
-
// Time window for tracking requests in milliseconds (default: 60,000ms = 1 minute)
|
|
17
|
-
windowMs: 60 * 1000,
|
|
18
|
-
|
|
19
|
-
// Maximum allowed requests from a single IP within the windowMs
|
|
20
|
-
maxRequests: 100,
|
|
21
|
-
|
|
22
|
-
// Directory where JSONL security event logs will be stored
|
|
23
|
-
logDir: path.resolve(process.cwd(), '.z-shield-logs'),
|
|
24
|
-
|
|
25
|
-
// Whether to trust reverse-proxy IP headers (e.g., X-Forwarded-For)
|
|
26
|
-
// CRITICAL: Must be false by default to prevent client IP spoofing attacks
|
|
27
|
-
trustProxy: false,
|
|
28
|
-
|
|
29
|
-
// Array of route prefixes that bypass abuse tracking (e.g. internal health checks)
|
|
30
|
-
excludeRoutes: ['/health', '/favicon.ico'],
|
|
31
|
-
|
|
32
|
-
// Maximum number of distinct client IPs to track in memory simultaneously
|
|
33
|
-
// Protects against Memory Exhaustion DoS attacks
|
|
34
|
-
maxTrackedIps: 10000,
|
|
35
|
-
|
|
36
|
-
// Duration in milliseconds for temporary IP blocks (default: 15 minutes)
|
|
37
|
-
blockDurationMs: 15 * 60 * 1000,
|
|
38
|
-
|
|
39
|
-
// Maximum number of blocked IPs to keep in memory simultaneously
|
|
40
|
-
maxBlockedIps: 5000,
|
|
41
|
-
|
|
42
|
-
// Whether exceeding rate limit in protect mode automatically triggers a temporary block
|
|
43
|
-
autoBlock: false,
|
|
44
|
-
|
|
45
|
-
// Filename for the active JSON Lines log file
|
|
46
|
-
logFilename: 'events.jsonl',
|
|
47
|
-
|
|
48
|
-
// Maximum size in bytes before rotating the log file (default: 5MB)
|
|
49
|
-
maxLogSizeBytes: 5 * 1024 * 1024,
|
|
50
|
-
|
|
51
|
-
// Maximum number of rotated log files to retain
|
|
52
|
-
maxLogFiles: 5,
|
|
53
|
-
|
|
54
|
-
// Optional custom error callback: (err, event) => void
|
|
55
|
-
onLogError: null
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Validates user-provided options and merges them with defaults.
|
|
60
|
-
* Throws clear, helpful errors if an invalid option is passed.
|
|
61
|
-
*
|
|
62
|
-
* @param {Object} [userConfig={}] - Custom configuration provided by the developer
|
|
63
|
-
* @returns {Readonly<Object>} Validated and frozen configuration object
|
|
64
|
-
*/
|
|
65
|
-
function resolveConfig(userConfig = {}) {
|
|
66
|
-
if (userConfig === null || typeof userConfig !== 'object' || Array.isArray(userConfig)) {
|
|
67
|
-
throw new TypeError('[Z-Shield] Configuration must be a plain object.');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const config = { ...DEFAULT_CONFIG, ...userConfig };
|
|
71
|
-
|
|
72
|
-
// Validate appName
|
|
73
|
-
if (typeof config.appName !== 'string' || config.appName.trim().length === 0) {
|
|
74
|
-
throw new TypeError('[Z-Shield] "appName" must be a non-empty string.');
|
|
75
|
-
}
|
|
76
|
-
// Prevent directory traversal characters in appName (e.g., "../hacker")
|
|
77
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(config.appName)) {
|
|
78
|
-
throw new Error('[Z-Shield] "appName" can only contain letters, numbers, dashes, and underscores.');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Validate mode
|
|
82
|
-
if (config.mode !== 'monitor' && config.mode !== 'protect') {
|
|
83
|
-
throw new Error(`[Z-Shield] Invalid mode "${config.mode}". Mode must be either "monitor" or "protect".`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Validate windowMs
|
|
87
|
-
if (typeof config.windowMs !== 'number' || !Number.isFinite(config.windowMs) || config.windowMs <= 0) {
|
|
88
|
-
throw new TypeError('[Z-Shield] "windowMs" must be a positive number in milliseconds.');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Validate maxRequests
|
|
92
|
-
if (typeof config.maxRequests !== 'number' || !Number.isInteger(config.maxRequests) || config.maxRequests <= 0) {
|
|
93
|
-
throw new TypeError('[Z-Shield] "maxRequests" must be a positive integer.');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Validate logDir
|
|
97
|
-
if (typeof config.logDir !== 'string' || config.logDir.trim().length === 0) {
|
|
98
|
-
throw new TypeError('[Z-Shield] "logDir" must be a non-empty string path.');
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Validate trustProxy
|
|
102
|
-
if (typeof config.trustProxy !== 'boolean') {
|
|
103
|
-
throw new TypeError('[Z-Shield] "trustProxy" must be a boolean (true or false).');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Validate excludeRoutes
|
|
107
|
-
if (!Array.isArray(config.excludeRoutes) || !config.excludeRoutes.every(r => typeof r === 'string')) {
|
|
108
|
-
throw new TypeError('[Z-Shield] "excludeRoutes" must be an array of string route prefixes.');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Validate maxTrackedIps
|
|
112
|
-
if (typeof config.maxTrackedIps !== 'number' || !Number.isInteger(config.maxTrackedIps) || config.maxTrackedIps <= 0) {
|
|
113
|
-
throw new TypeError('[Z-Shield] "maxTrackedIps" must be a positive integer.');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Validate blockDurationMs
|
|
117
|
-
if (typeof config.blockDurationMs !== 'number' || !Number.isFinite(config.blockDurationMs) || config.blockDurationMs <= 0) {
|
|
118
|
-
throw new TypeError('[Z-Shield] "blockDurationMs" must be a positive number in milliseconds.');
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Validate maxBlockedIps
|
|
122
|
-
if (typeof config.maxBlockedIps !== 'number' || !Number.isInteger(config.maxBlockedIps) || config.maxBlockedIps <= 0) {
|
|
123
|
-
throw new TypeError('[Z-Shield] "maxBlockedIps" must be a positive integer.');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Validate autoBlock
|
|
127
|
-
if (typeof config.autoBlock !== 'boolean') {
|
|
128
|
-
throw new TypeError('[Z-Shield] "autoBlock" must be a boolean (true or false).');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Validate logFilename
|
|
132
|
-
if (typeof config.logFilename !== 'string' || config.logFilename.trim().length === 0) {
|
|
133
|
-
throw new TypeError('[Z-Shield] "logFilename" must be a non-empty string.');
|
|
134
|
-
}
|
|
135
|
-
if (!/^[a-zA-Z0-9._-]+$/.test(config.logFilename) || config.logFilename.includes('..')) {
|
|
136
|
-
throw new Error('[Z-Shield] "logFilename" must be a safe filename without path traversal characters.');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Validate maxLogSizeBytes
|
|
140
|
-
if (typeof config.maxLogSizeBytes !== 'number' || !Number.isInteger(config.maxLogSizeBytes) || config.maxLogSizeBytes <= 0) {
|
|
141
|
-
throw new TypeError('[Z-Shield] "maxLogSizeBytes" must be a positive integer.');
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Validate maxLogFiles
|
|
145
|
-
if (typeof config.maxLogFiles !== 'number' || !Number.isInteger(config.maxLogFiles) || config.maxLogFiles < 0) {
|
|
146
|
-
throw new TypeError('[Z-Shield] "maxLogFiles" must be a non-negative integer.');
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Validate onLogError
|
|
150
|
-
if (config.onLogError !== null && typeof config.onLogError !== 'function') {
|
|
151
|
-
throw new TypeError('[Z-Shield] "onLogError" must be null or a function.');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Freeze the object so application code cannot accidentally mutate it at runtime
|
|
155
|
-
return Object.freeze({
|
|
156
|
-
...config,
|
|
157
|
-
logDir: path.resolve(config.logDir),
|
|
158
|
-
excludeRoutes: Object.freeze([...config.excludeRoutes])
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
module.exports = {
|
|
163
|
-
DEFAULT_CONFIG,
|
|
164
|
-
resolveConfig
|
|
165
|
-
};
|
package/src/detector.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Detector - Evaluates incoming requests and detects abuse patterns.
|
|
5
|
-
*
|
|
6
|
-
* In Phase 2, this evaluates whether a client IP has exceeded the
|
|
7
|
-
* maximum request threshold within the configured time window.
|
|
8
|
-
*/
|
|
9
|
-
class Detector {
|
|
10
|
-
/**
|
|
11
|
-
* @param {Object} options
|
|
12
|
-
* @param {import('./rate-limiter').RateLimiter} options.rateLimiter - The rate-limiter instance
|
|
13
|
-
* @param {Object} options.config - The resolved Z-Shield configuration
|
|
14
|
-
*/
|
|
15
|
-
constructor({ rateLimiter, config }) {
|
|
16
|
-
if (!rateLimiter) {
|
|
17
|
-
throw new TypeError('[Z-Shield] Detector requires a RateLimiter instance.');
|
|
18
|
-
}
|
|
19
|
-
if (!config) {
|
|
20
|
-
throw new TypeError('[Z-Shield] Detector requires a resolved config object.');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
this.rateLimiter = rateLimiter;
|
|
24
|
-
this.config = config;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Checks an incoming request against detection rules.
|
|
29
|
-
*
|
|
30
|
-
* @param {string} ip - Client IP address
|
|
31
|
-
* @param {import('express').Request} [req={}] - Express request object
|
|
32
|
-
* @param {number} [now=Date.now()] - Timestamp
|
|
33
|
-
* @returns {Object} Detection evaluation result
|
|
34
|
-
*/
|
|
35
|
-
check(ip, req = {}, now = Date.now()) {
|
|
36
|
-
const status = this.rateLimiter.record(ip, now);
|
|
37
|
-
|
|
38
|
-
if (status.isExceeded) {
|
|
39
|
-
const windowSeconds = Math.round(this.config.windowMs / 1000);
|
|
40
|
-
return {
|
|
41
|
-
isSuspicious: true,
|
|
42
|
-
type: 'rate_limit',
|
|
43
|
-
severity: 'warning',
|
|
44
|
-
reason: `Request threshold exceeded (${status.count}/${this.config.maxRequests} in ${windowSeconds}s)`,
|
|
45
|
-
count: status.count,
|
|
46
|
-
maxRequests: this.config.maxRequests,
|
|
47
|
-
remaining: status.remaining,
|
|
48
|
-
resetTime: status.resetTime,
|
|
49
|
-
timestamp: new Date(now).toISOString()
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
isSuspicious: false,
|
|
55
|
-
type: 'normal',
|
|
56
|
-
severity: 'info',
|
|
57
|
-
reason: null,
|
|
58
|
-
count: status.count,
|
|
59
|
-
maxRequests: this.config.maxRequests,
|
|
60
|
-
remaining: status.remaining,
|
|
61
|
-
resetTime: status.resetTime,
|
|
62
|
-
timestamp: new Date(now).toISOString()
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Inspects current status without incrementing counters.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} ip
|
|
70
|
-
* @param {number} [now=Date.now()]
|
|
71
|
-
* @returns {Object}
|
|
72
|
-
*/
|
|
73
|
-
inspect(ip, now = Date.now()) {
|
|
74
|
-
const status = this.rateLimiter.get(ip, now);
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
isSuspicious: status.isExceeded,
|
|
78
|
-
count: status.count,
|
|
79
|
-
maxRequests: this.config.maxRequests,
|
|
80
|
-
remaining: status.remaining,
|
|
81
|
-
resetTime: status.resetTime
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
module.exports = {
|
|
87
|
-
Detector
|
|
88
|
-
};
|
package/src/index.js
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { createMiddleware } = require('./middleware');
|
|
4
|
-
const { resolveConfig, DEFAULT_CONFIG } = require('./config');
|
|
5
|
-
const { RateLimiter } = require('./rate-limiter');
|
|
6
|
-
const { Detector } = require('./detector');
|
|
7
|
-
const { BlockManager } = require('./block-manager');
|
|
8
|
-
const { Logger } = require('./logger');
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Main factory function for initializing Z-Shield.
|
|
12
|
-
*
|
|
13
|
-
* Usage in Express:
|
|
14
|
-
* const { createShield } = require('z-shield');
|
|
15
|
-
* const shield = createShield({ mode: 'protect', maxRequests: 50 });
|
|
16
|
-
* app.use(shield.middleware);
|
|
17
|
-
*
|
|
18
|
-
* Programmatic Block API:
|
|
19
|
-
* shield.block('198.51.100.1', '10m', 'Manual administrative block');
|
|
20
|
-
* shield.unblock('198.51.100.1');
|
|
21
|
-
* shield.isBlocked('198.51.100.1');
|
|
22
|
-
*
|
|
23
|
-
* @param {Object} [options={}] - Custom configuration options
|
|
24
|
-
* @returns {Object} Z-Shield instance with middleware, managers, logger, and programmatic methods
|
|
25
|
-
*/
|
|
26
|
-
function createShield(options = {}) {
|
|
27
|
-
const config = resolveConfig(options);
|
|
28
|
-
const rateLimiter = new RateLimiter(config);
|
|
29
|
-
const detector = new Detector({ rateLimiter, config });
|
|
30
|
-
const blockManager = new BlockManager(config);
|
|
31
|
-
const logger = new Logger(config);
|
|
32
|
-
|
|
33
|
-
const middleware = createMiddleware(config, {
|
|
34
|
-
rateLimiter,
|
|
35
|
-
detector,
|
|
36
|
-
blockManager,
|
|
37
|
-
logger
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
middleware,
|
|
42
|
-
config,
|
|
43
|
-
rateLimiter,
|
|
44
|
-
detector,
|
|
45
|
-
blockManager,
|
|
46
|
-
logger,
|
|
47
|
-
|
|
48
|
-
// Programmatic Block API with security event logging
|
|
49
|
-
block: (ip, duration, reason) => {
|
|
50
|
-
const result = blockManager.block(ip, duration, reason);
|
|
51
|
-
logger.log({
|
|
52
|
-
appName: config.appName,
|
|
53
|
-
type: 'manual_block',
|
|
54
|
-
severity: 'info',
|
|
55
|
-
ip,
|
|
56
|
-
method: 'INTERNAL',
|
|
57
|
-
route: '/admin/block',
|
|
58
|
-
reason: result.reason,
|
|
59
|
-
action: 'blocked'
|
|
60
|
-
});
|
|
61
|
-
return result;
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
unblock: (ip) => {
|
|
65
|
-
const result = blockManager.unblock(ip);
|
|
66
|
-
if (result) {
|
|
67
|
-
logger.log({
|
|
68
|
-
appName: config.appName,
|
|
69
|
-
type: 'manual_unblock',
|
|
70
|
-
severity: 'info',
|
|
71
|
-
ip,
|
|
72
|
-
method: 'INTERNAL',
|
|
73
|
-
route: '/admin/unblock',
|
|
74
|
-
reason: 'Manual unblock requested',
|
|
75
|
-
action: 'unblocked'
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
return result;
|
|
79
|
-
},
|
|
80
|
-
|
|
81
|
-
isBlocked: (ip) => blockManager.isBlocked(ip),
|
|
82
|
-
getBlockInfo: (ip) => blockManager.getBlockInfo(ip),
|
|
83
|
-
listBlocks: () => blockManager.listBlocks(),
|
|
84
|
-
flush: () => logger.flush()
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
module.exports = {
|
|
89
|
-
createShield,
|
|
90
|
-
createMiddleware,
|
|
91
|
-
resolveConfig,
|
|
92
|
-
DEFAULT_CONFIG,
|
|
93
|
-
RateLimiter,
|
|
94
|
-
Detector,
|
|
95
|
-
BlockManager,
|
|
96
|
-
Logger
|
|
97
|
-
};
|
package/src/logger.js
DELETED
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Strips ANSI terminal escape codes from untrusted strings to prevent terminal injection.
|
|
8
|
-
* @param {string} str
|
|
9
|
-
* @returns {string}
|
|
10
|
-
*/
|
|
11
|
-
function stripAnsi(str) {
|
|
12
|
-
// Matches standard ANSI color and control escape sequences
|
|
13
|
-
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Sanitizes an untrusted string to prevent Log Injection (CWE-117).
|
|
18
|
-
* - Strips ANSI escapes
|
|
19
|
-
* - Strips control characters (newlines \r, \n, null bytes, backspaces, tabs)
|
|
20
|
-
* - Truncates to maxLength to prevent disk space exhaustion
|
|
21
|
-
*
|
|
22
|
-
* @param {*} val
|
|
23
|
-
* @param {number} [maxLength=250]
|
|
24
|
-
* @returns {string}
|
|
25
|
-
*/
|
|
26
|
-
function sanitizeString(val, maxLength = 250) {
|
|
27
|
-
if (val === null || val === undefined) {
|
|
28
|
-
return '';
|
|
29
|
-
}
|
|
30
|
-
let str = String(val);
|
|
31
|
-
str = stripAnsi(str);
|
|
32
|
-
// Replace control characters (ASCII 0-31, 127) with a clean space
|
|
33
|
-
str = str.replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
|
34
|
-
// Collapse consecutive spaces
|
|
35
|
-
str = str.replace(/\s+/g, ' ');
|
|
36
|
-
return str.slice(0, maxLength);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Sanitizes HTTP route paths.
|
|
41
|
-
* @param {*} route
|
|
42
|
-
* @param {number} [maxLength=500]
|
|
43
|
-
* @returns {string}
|
|
44
|
-
*/
|
|
45
|
-
function sanitizeRoute(route, maxLength = 500) {
|
|
46
|
-
if (!route || typeof route !== 'string') {
|
|
47
|
-
return '/';
|
|
48
|
-
}
|
|
49
|
-
let clean = route.split(/[\r\n]/)[0];
|
|
50
|
-
clean = stripAnsi(clean);
|
|
51
|
-
// Strip query parameters and URL fragments to prevent credential leakage (CWE-532)
|
|
52
|
-
clean = clean.split('?')[0].split('#')[0];
|
|
53
|
-
// Remove dangerous control characters and newlines
|
|
54
|
-
clean = clean.replace(/[\x00-\x1f\x7f]/g, '').trim();
|
|
55
|
-
if (!clean.startsWith('/')) {
|
|
56
|
-
clean = '/' + clean;
|
|
57
|
-
}
|
|
58
|
-
return clean.slice(0, maxLength);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Sanitizes HTTP methods.
|
|
63
|
-
* @param {*} method
|
|
64
|
-
* @returns {string}
|
|
65
|
-
*/
|
|
66
|
-
function sanitizeMethod(method) {
|
|
67
|
-
if (!method || typeof method !== 'string') {
|
|
68
|
-
return 'GET';
|
|
69
|
-
}
|
|
70
|
-
const clean = method.toUpperCase().replace(/[^A-Z]/g, '');
|
|
71
|
-
return clean.slice(0, 10) || 'GET';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Logger - Safe, Persistent Local JSON Lines Security Event Logger
|
|
76
|
-
*
|
|
77
|
-
* Design Features:
|
|
78
|
-
* - JSON Lines (.jsonl): Every event is on exactly one line ending in \n.
|
|
79
|
-
* - Non-Blocking & Asynchronous: Uses node:fs/promises without blocking the event loop.
|
|
80
|
-
* - Strict Sanitization: Eliminates Log Injection (CWE-117) by stripping newlines and ANSI escapes.
|
|
81
|
-
* - Privacy-Preserving: Never logs passwords, cookies, authorization headers, or request bodies (CWE-532).
|
|
82
|
-
* - Bounded Storage: Automatic log rotation and retention limits prevent disk space exhaustion.
|
|
83
|
-
* - Resilient: Filesystem errors (ENOSPC, EACCES) are safely caught and will not crash the host app.
|
|
84
|
-
* - Sequential Queue: Guarantees concurrency safety so simultaneous writes never corrupt files.
|
|
85
|
-
*
|
|
86
|
-
* Hosting Notice:
|
|
87
|
-
* Logs are stored in local persistent storage. If deployed to ephemeral platforms
|
|
88
|
-
* (e.g. basic Heroku dynos, AWS Lambda, or serverless containers without mounted volumes),
|
|
89
|
-
* local log files may be lost upon container restart or redeployment.
|
|
90
|
-
*/
|
|
91
|
-
class Logger {
|
|
92
|
-
/**
|
|
93
|
-
* @param {Object} [config={}] - Resolved Z-Shield configuration
|
|
94
|
-
*/
|
|
95
|
-
constructor(config = {}) {
|
|
96
|
-
this.appName = config.appName || 'default-app';
|
|
97
|
-
this.logDir = path.resolve(config.logDir || path.join(process.cwd(), '.z-shield-logs'));
|
|
98
|
-
this.logFilename = config.logFilename || 'events.jsonl';
|
|
99
|
-
this.filePath = path.join(this.logDir, this.logFilename);
|
|
100
|
-
this.maxLogSizeBytes = config.maxLogSizeBytes || 5 * 1024 * 1024; // 5MB
|
|
101
|
-
this.maxLogFiles = typeof config.maxLogFiles === 'number' ? config.maxLogFiles : 5;
|
|
102
|
-
this.onLogError = typeof config.onLogError === 'function' ? config.onLogError : null;
|
|
103
|
-
|
|
104
|
-
this._writeQueue = Promise.resolve();
|
|
105
|
-
this._dirEnsured = false;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Formats and sanitizes a security event into a validated schema.
|
|
110
|
-
* Deliberately omits request bodies, authorization headers, cookies, and tokens.
|
|
111
|
-
*
|
|
112
|
-
* @param {Object} rawEvent
|
|
113
|
-
* @returns {Object} Validated security event
|
|
114
|
-
*/
|
|
115
|
-
formatEvent(rawEvent = {}) {
|
|
116
|
-
const now = rawEvent.timestamp ? new Date(rawEvent.timestamp) : new Date();
|
|
117
|
-
const timestamp = isNaN(now.getTime()) ? new Date().toISOString() : now.toISOString();
|
|
118
|
-
|
|
119
|
-
const allowedSeverities = ['info', 'warning', 'critical'];
|
|
120
|
-
const severity = allowedSeverities.includes(rawEvent.severity) ? rawEvent.severity : 'warning';
|
|
121
|
-
|
|
122
|
-
const allowedActions = ['blocked', 'warned', 'allowed', 'unblocked'];
|
|
123
|
-
const action = allowedActions.includes(rawEvent.action) ? rawEvent.action : 'warned';
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
timestamp,
|
|
127
|
-
appName: sanitizeString(rawEvent.appName || this.appName, 50),
|
|
128
|
-
type: sanitizeString(rawEvent.type || 'security_event', 30),
|
|
129
|
-
severity,
|
|
130
|
-
ip: sanitizeString(rawEvent.ip || 'unknown', 45),
|
|
131
|
-
method: sanitizeMethod(rawEvent.method),
|
|
132
|
-
route: sanitizeRoute(rawEvent.route),
|
|
133
|
-
reason: sanitizeString(rawEvent.reason || 'Unspecified security event', 300),
|
|
134
|
-
action
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Safely logs a security event asynchronously.
|
|
140
|
-
* Resolves to true if write succeeded, or false if it failed.
|
|
141
|
-
*
|
|
142
|
-
* @param {Object} rawEvent
|
|
143
|
-
* @returns {Promise<boolean>}
|
|
144
|
-
*/
|
|
145
|
-
log(rawEvent) {
|
|
146
|
-
return new Promise((resolve) => {
|
|
147
|
-
this._writeQueue = this._writeQueue
|
|
148
|
-
.then(() => this._writeEvent(rawEvent))
|
|
149
|
-
.then(() => resolve(true))
|
|
150
|
-
.catch((err) => {
|
|
151
|
-
this._handleError(err, rawEvent);
|
|
152
|
-
resolve(false);
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Waits for all currently queued asynchronous log writes to complete.
|
|
159
|
-
* Useful for deterministic testing, graceful shutdowns, and process exit handlers.
|
|
160
|
-
*
|
|
161
|
-
* @returns {Promise<void>}
|
|
162
|
-
*/
|
|
163
|
-
async flush() {
|
|
164
|
-
await this._writeQueue;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Internal write operation executed within the sequential queue.
|
|
169
|
-
*
|
|
170
|
-
* @private
|
|
171
|
-
* @param {Object} rawEvent
|
|
172
|
-
*/
|
|
173
|
-
async _writeEvent(rawEvent) {
|
|
174
|
-
const event = this.formatEvent(rawEvent);
|
|
175
|
-
await this._ensureDir();
|
|
176
|
-
await this._rotateIfNeeded();
|
|
177
|
-
|
|
178
|
-
const line = JSON.stringify(event) + '\n';
|
|
179
|
-
await fs.promises.appendFile(this.filePath, line, { encoding: 'utf8', flag: 'a' });
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Ensures the target log directory exists.
|
|
184
|
-
* @private
|
|
185
|
-
*/
|
|
186
|
-
async _ensureDir() {
|
|
187
|
-
if (!this._dirEnsured) {
|
|
188
|
-
await fs.promises.mkdir(this.logDir, { recursive: true });
|
|
189
|
-
this._dirEnsured = true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Checks file size and rotates log files if size exceeds maxLogSizeBytes.
|
|
195
|
-
* @private
|
|
196
|
-
*/
|
|
197
|
-
async _rotateIfNeeded() {
|
|
198
|
-
try {
|
|
199
|
-
const stats = await fs.promises.stat(this.filePath);
|
|
200
|
-
if (stats.size < this.maxLogSizeBytes) {
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
} catch (err) {
|
|
204
|
-
if (err.code === 'ENOENT') {
|
|
205
|
-
return; // File does not exist yet
|
|
206
|
-
}
|
|
207
|
-
throw err;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const { name, ext } = path.parse(this.logFilename);
|
|
211
|
-
|
|
212
|
-
// If maxLogFiles is 0, simply remove the current file
|
|
213
|
-
if (this.maxLogFiles === 0) {
|
|
214
|
-
try {
|
|
215
|
-
await fs.promises.unlink(this.filePath);
|
|
216
|
-
} catch (err) {
|
|
217
|
-
if (err.code !== 'ENOENT') throw err;
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Shift existing rotated files:
|
|
223
|
-
// e.g. events.5.jsonl deleted (retention limit)
|
|
224
|
-
// events.4.jsonl -> events.5.jsonl
|
|
225
|
-
// events.1.jsonl -> events.2.jsonl
|
|
226
|
-
for (let i = this.maxLogFiles; i >= 1; i--) {
|
|
227
|
-
const currentRotated = path.join(this.logDir, `${name}.${i}${ext}`);
|
|
228
|
-
if (i === this.maxLogFiles) {
|
|
229
|
-
try {
|
|
230
|
-
await fs.promises.unlink(currentRotated);
|
|
231
|
-
} catch (err) {
|
|
232
|
-
if (err.code !== 'ENOENT') throw err;
|
|
233
|
-
}
|
|
234
|
-
} else {
|
|
235
|
-
const nextRotated = path.join(this.logDir, `${name}.${i + 1}${ext}`);
|
|
236
|
-
try {
|
|
237
|
-
await fs.promises.rename(currentRotated, nextRotated);
|
|
238
|
-
} catch (err) {
|
|
239
|
-
if (err.code !== 'ENOENT') throw err;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Move current events.jsonl to events.1.jsonl
|
|
245
|
-
const firstRotated = path.join(this.logDir, `${name}.1${ext}`);
|
|
246
|
-
try {
|
|
247
|
-
await fs.promises.rename(this.filePath, firstRotated);
|
|
248
|
-
} catch (err) {
|
|
249
|
-
if (err.code !== 'ENOENT') throw err;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Safely handles write and filesystem failures without crashing the application.
|
|
255
|
-
*
|
|
256
|
-
* @private
|
|
257
|
-
* @param {Error} err
|
|
258
|
-
* @param {Object} rawEvent
|
|
259
|
-
*/
|
|
260
|
-
_handleError(err, rawEvent) {
|
|
261
|
-
if (this.onLogError) {
|
|
262
|
-
try {
|
|
263
|
-
this.onLogError(err, rawEvent);
|
|
264
|
-
} catch {
|
|
265
|
-
// Prevent errors in the user's callback from crashing the app
|
|
266
|
-
}
|
|
267
|
-
} else {
|
|
268
|
-
// Print clear, non-fatal warning to stderr
|
|
269
|
-
console.error(`[Z-Shield Logger Warning]: Failed to write security event log: ${err.message}`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
module.exports = {
|
|
275
|
-
Logger,
|
|
276
|
-
sanitizeString,
|
|
277
|
-
sanitizeRoute,
|
|
278
|
-
sanitizeMethod,
|
|
279
|
-
stripAnsi
|
|
280
|
-
};
|