i18ntk 2.3.7 → 2.4.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/utils/security.js CHANGED
@@ -1,26 +1,93 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
- const crypto = require('crypto');
4
-
5
-
6
- // Lazy load configManager to avoid circular dependency
7
- let configManager;
8
- let configManagerLoadAttempted = false;
9
- function getConfigManager() {
10
- if (!configManager && !configManagerLoadAttempted) {
11
- configManagerLoadAttempted = true;
12
- try {
13
- configManager = require('./config-manager');
14
- } catch (error) {
15
- // Return null if config-manager can't be loaded
16
- return null;
17
- }
18
- }
19
- return configManager;
20
- }
21
-
22
- // Lazy load i18n to prevent initialization race conditions
23
- let i18n;
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const crypto = require('crypto');
4
+ const { logger } = require('./logger');
5
+
6
+ const INTERNAL_MANIFEST_CACHE = {
7
+ initialized: false,
8
+ roots: new Set()
9
+ };
10
+
11
+ function findPackageRoot(startDir) {
12
+ let current = path.resolve(startDir || process.cwd());
13
+ while (true) {
14
+ const manifest = path.join(current, 'package.json');
15
+ if (fs.existsSync(manifest)) {
16
+ return current;
17
+ }
18
+ const parent = path.dirname(current);
19
+ if (parent === current) {
20
+ return null;
21
+ }
22
+ current = parent;
23
+ }
24
+ }
25
+
26
+ function initializeInternalRoots() {
27
+ if (INTERNAL_MANIFEST_CACHE.initialized) {
28
+ return INTERNAL_MANIFEST_CACHE.roots;
29
+ }
30
+
31
+ INTERNAL_MANIFEST_CACHE.initialized = true;
32
+ const roots = INTERNAL_MANIFEST_CACHE.roots;
33
+ const candidates = [
34
+ process.cwd(),
35
+ path.resolve(__dirname, '..'),
36
+ findPackageRoot(process.cwd()),
37
+ findPackageRoot(path.resolve(__dirname, '..'))
38
+ ].filter(Boolean);
39
+
40
+ for (const candidate of candidates) {
41
+ roots.add(path.resolve(candidate));
42
+ }
43
+
44
+ const custom = String(process.env.I18NTK_INTERNAL_PATH_PREFIXES || '')
45
+ .split(',')
46
+ .map((entry) => entry.trim())
47
+ .filter(Boolean);
48
+ for (const prefix of custom) {
49
+ roots.add(path.resolve(prefix));
50
+ }
51
+
52
+ return roots;
53
+ }
54
+
55
+ function normalizeForCompare(value) {
56
+ return String(value || '').replace(/\\/g, '/').toLowerCase();
57
+ }
58
+
59
+ function isPathInside(root, target) {
60
+ const normalizedRoot = normalizeForCompare(path.resolve(root));
61
+ const normalizedTarget = normalizeForCompare(path.resolve(target));
62
+ return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}/`);
63
+ }
64
+
65
+ function isInternalPath(inputPath) {
66
+ if (!inputPath || typeof inputPath !== 'string') {
67
+ return false;
68
+ }
69
+ const roots = initializeInternalRoots();
70
+ const absolute = path.resolve(inputPath);
71
+ for (const root of roots) {
72
+ if (isPathInside(root, absolute)) {
73
+ return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
79
+ function detectDangerReason(filePath) {
80
+ if (/\.\.(\/|\\|$)/.test(filePath)) return 'Contains parent directory traversal segments';
81
+ if (/(^|[\/\\])~([\/\\]|$)/.test(filePath)) return 'Contains home-directory shorthand';
82
+ if (/\$\{/.test(filePath)) return 'Contains variable expansion token';
83
+ if (/`/.test(filePath)) return 'Contains command substitution token';
84
+ if (/[|;&<>]/.test(filePath)) return 'Contains shell metacharacters';
85
+ return null;
86
+ }
87
+
88
+
89
+ // Lazy load i18n to prevent initialization race conditions
90
+ let i18n;
24
91
  function getI18n() {
25
92
  if (!i18n) {
26
93
  try {
@@ -74,11 +141,13 @@ static _logging = false;
74
141
  SecurityUtils._operationStack = new Set();
75
142
  }
76
143
 
77
- if (SecurityUtils._operationStack.has(operationName)) {
78
- const i18n = getI18n();
79
- SecurityUtils.logSecurityEvent(i18n.t('security.recursion_detected', { operation: operationName }), 'error');
80
- return null;
81
- }
144
+ if (SecurityUtils._operationStack.has(operationName)) {
145
+ SecurityUtils.logSecurityEvent('Recursion detected while performing secure operation', 'warn', {
146
+ operation: operationName,
147
+ source: 'internal'
148
+ });
149
+ return null;
150
+ }
82
151
 
83
152
  SecurityUtils._operationStack.add(operationName);
84
153
 
@@ -88,12 +157,15 @@ static _logging = false;
88
157
  let hasResult = false;
89
158
  let timeoutId = null;
90
159
 
91
- timeoutId = setTimeout(() => {
92
- if (!hasResult) {
93
- const i18n = getI18n();
94
- SecurityUtils.logSecurityEvent(i18n.t('security.operation_timeout', { operation: operationName }), 'warning');
95
- }
96
- }, timeoutMs);
160
+ timeoutId = setTimeout(() => {
161
+ if (!hasResult) {
162
+ SecurityUtils.logSecurityEvent('Secure operation timeout', 'warn', {
163
+ operation: operationName,
164
+ timeoutMs,
165
+ source: 'internal'
166
+ });
167
+ }
168
+ }, timeoutMs);
97
169
 
98
170
  // Execute operation synchronously
99
171
  result = operation();
@@ -105,12 +177,12 @@ static _logging = false;
105
177
 
106
178
  return result;
107
179
  } catch (error) {
108
- const i18n = getI18n();
109
- SecurityUtils.logSecurityEvent('Operation error', 'error', {
110
- operation: operationName,
111
- error: error.message
112
- });
113
- return null;
180
+ SecurityUtils.logSecurityEvent('Secure operation error', 'error', {
181
+ operation: operationName,
182
+ error: error.message,
183
+ source: 'internal'
184
+ });
185
+ return null;
114
186
  } finally {
115
187
  SecurityUtils._operationStack.delete(operationName);
116
188
  }
@@ -122,60 +194,40 @@ static _logging = false;
122
194
  * @param {string} level - Log level (info, warn, error)
123
195
  * @param {object} details - Additional details
124
196
  */
125
- static logSecurityEvent(event, level = 'info', details = {}) {
126
- // Prevent recursive logging which can occur during configuration loading
127
- if (SecurityUtils._logging) {
128
- return;
129
- }
130
-
131
- SecurityUtils._logging = true;
132
- try {
133
- const cfg = getConfigManager()?.getConfig?.() || {};
134
- const envLevel = (process.env.SECURITY_LOG_LEVEL || process.env.I18NTK_SECURITY_LOG_LEVEL || '').toLowerCase();
135
- const configLevel = (cfg.security?.logLevel || cfg.security?.audit?.logLevel || '').toLowerCase();
136
-
137
- // Check for debug mode
138
- const debugMode = process.env.I18N_DEBUG === 'true' || process.env.DEBUG === 'true';
139
- const currentLevel = debugMode ? 'info' : (envLevel || configLevel || 'warn');
140
-
141
- const levels = { error: 0, warn: 1, warning: 1, info: 2 };
142
- const messageLevel = levels[level.toLowerCase()] ?? 2;
143
- const allowedLevel = levels[currentLevel] ?? 1;
144
- if (messageLevel > allowedLevel) {
145
- return;
146
- }
147
-
148
- const timestamp = new Date().toISOString();
149
- const logEntry = {
150
- timestamp,
151
- level,
152
- event,
153
- details: {
154
- ...details,
155
- pid: process.pid,
156
- nodeVersion: process.version
157
- }
158
- };
159
-
160
- const message = `[SECURITY ${level.toUpperCase()}] ${timestamp}: ${event}`;
161
- if (level === 'error') {
162
- console.error(message, details);
163
- } else if (level === 'warn' || level === 'warning') {
164
- console.warn(message, details);
165
- } else {
166
- console.log(message, details);
167
- }
168
- } finally {
169
- SecurityUtils._logging = false;
170
- }
171
- }
197
+ static logSecurityEvent(event, level = 'info', details = {}) {
198
+ if (SecurityUtils._logging) {
199
+ return;
200
+ }
201
+
202
+ SecurityUtils._logging = true;
203
+ try {
204
+ const debugMode = logger.isDebugMode();
205
+ const explicitSecurityLogs = process.env.I18NTK_ENABLE_SECURITY_LOGS === 'true';
206
+ const source = details && details.source ? String(details.source).toLowerCase() : 'internal';
207
+ const levelName = String(level || 'info').toLowerCase();
208
+ const normalizedLevel = levelName === 'warning' ? 'warn' : levelName;
209
+
210
+ // Security warnings from internal paths are noise during builds.
211
+ if (!debugMode && !explicitSecurityLogs && source !== 'user') {
212
+ return;
213
+ }
214
+
215
+ logger.security(normalizedLevel, event, {
216
+ ...details,
217
+ pid: process.pid,
218
+ nodeVersion: process.version
219
+ });
220
+ } finally {
221
+ SecurityUtils._logging = false;
222
+ }
223
+ }
172
224
 
173
225
  // Add other static methods here...
174
- static validatePath(filePath, basePath = process.cwd(), verbose = false) {
175
- const i18n = getI18n();
176
- const useI18n = i18n && i18n.isInitialized && typeof i18n.t === 'function';
177
-
178
- try {
226
+ static validatePath(filePath, basePath = process.cwd(), verbose = false) {
227
+ const i18n = getI18n();
228
+ const useI18n = i18n && i18n.isInitialized && typeof i18n.t === 'function';
229
+
230
+ try {
179
231
  // Check against whitelist patterns for our own package artifacts
180
232
  if (SecurityUtils.PACKAGE_ARTIFACT_WHITELIST.some(pattern => pattern.test(filePath))) {
181
233
  return filePath;
@@ -195,17 +247,42 @@ static _logging = false;
195
247
  return null;
196
248
  }
197
249
 
198
- // Check for obvious dangerous patterns first
199
- if (!SecurityUtils.isSafePath(filePath)) {
200
- const message = useI18n
201
- ? i18n.t('security.pathTraversalAttempt')
202
- : 'Path traversal attempt';
203
- SecurityUtils.logSecurityEvent(message, 'warning', {
204
- inputPath: filePath,
205
- reason: 'Contains dangerous patterns'
206
- });
207
- return null;
208
- }
250
+ const isWindowsAbsolute = /^[A-Z]:[\/\\]/i.test(filePath);
251
+ const isUnixAbsolute = filePath.startsWith('/') || filePath.startsWith('\\');
252
+ const isAbsolutePath = isWindowsAbsolute || isUnixAbsolute;
253
+ const dangerousReason = detectDangerReason(filePath);
254
+ const source = isInternalPath(filePath) ? 'internal' : 'user';
255
+
256
+ if (isAbsolutePath && isInternalPath(filePath)) {
257
+ return path.resolve(filePath);
258
+ }
259
+
260
+ // For absolute paths, defer trust decision to base-path containment checks below,
261
+ // but still reject obvious shell/injection markers.
262
+ if (isAbsolutePath && dangerousReason) {
263
+ const message = useI18n
264
+ ? i18n.t('security.pathTraversalAttempt')
265
+ : 'Path traversal attempt';
266
+ SecurityUtils.logSecurityEvent(message, 'warning', {
267
+ inputPath: filePath,
268
+ reason: dangerousReason,
269
+ source
270
+ });
271
+ return null;
272
+ }
273
+
274
+ // Check for obvious dangerous patterns first for relative paths
275
+ if (!isAbsolutePath && !SecurityUtils.isSafePath(filePath)) {
276
+ const message = useI18n
277
+ ? i18n.t('security.pathTraversalAttempt')
278
+ : 'Path traversal attempt';
279
+ SecurityUtils.logSecurityEvent(message, 'warning', {
280
+ inputPath: filePath,
281
+ reason: dangerousReason || 'Contains unsafe path segments',
282
+ source
283
+ });
284
+ return null;
285
+ }
209
286
 
210
287
  // Resolve base and target paths
211
288
  const base = fs.realpathSync(basePath);
@@ -220,44 +297,47 @@ static _logging = false;
220
297
  }
221
298
 
222
299
  // Check for actual path traversal (going outside the base directory)
223
- const relativePath = path.relative(base, finalPath);
224
- if (relativePath.startsWith('..')) {
225
- const message = useI18n
226
- ? i18n.t('security.pathTraversalAttempt')
227
- : 'Path traversal attempt';
228
- SecurityUtils.logSecurityEvent(message, 'warning', {
229
- inputPath: filePath,
230
- resolvedPath: finalPath,
231
- basePath: base,
232
- relativePath: relativePath
233
- });
234
- return null;
235
- }
300
+ const relativePath = path.relative(base, finalPath);
301
+ if (relativePath.startsWith('..')) {
302
+ const message = useI18n
303
+ ? i18n.t('security.pathTraversalAttempt')
304
+ : 'Path traversal attempt';
305
+ SecurityUtils.logSecurityEvent(message, 'warning', {
306
+ inputPath: filePath,
307
+ resolvedPath: finalPath,
308
+ basePath: base,
309
+ relativePath: relativePath,
310
+ source
311
+ });
312
+ return null;
313
+ }
236
314
 
237
315
  // Allow absolute paths that resolve within the project structure
238
316
  // The isSafePath check above already filtered out dangerous absolute paths
239
317
 
240
318
  if (verbose) {
241
- const successMsg = useI18n
242
- ? i18n.t('security.pathValidated')
243
- : 'Path validated';
244
- SecurityUtils.logSecurityEvent(successMsg, 'info', {
245
- inputPath: filePath,
246
- resolvedPath: finalPath
247
- });
248
- }
249
- return finalPath;
319
+ const successMsg = useI18n
320
+ ? i18n.t('security.pathValidated')
321
+ : 'Path validated';
322
+ SecurityUtils.logSecurityEvent(successMsg, 'info', {
323
+ inputPath: filePath,
324
+ resolvedPath: finalPath,
325
+ source
326
+ });
327
+ }
328
+ return finalPath;
250
329
  } catch (error) {
251
330
  const message = useI18n
252
331
  ? i18n.t('security.pathValidationError')
253
332
  : 'Path validation error';
254
- SecurityUtils.logSecurityEvent(message, 'error', {
255
- inputPath: filePath,
256
- error: error.message
257
- });
258
- return null;
259
- }
260
- }
333
+ SecurityUtils.logSecurityEvent(message, 'error', {
334
+ inputPath: filePath,
335
+ error: error.message,
336
+ source: isInternalPath(filePath) ? 'internal' : 'user'
337
+ });
338
+ return null;
339
+ }
340
+ }
261
341
 
262
342
  static safeExistsSync(filePath, basePath, timeoutMs = 3000) {
263
343
  return this.withTimeoutSync(() => {
@@ -489,21 +569,23 @@ static _logging = false;
489
569
  const resolvedPath = path.resolve(joinedPath);
490
570
 
491
571
  // Ensure the final path is within the base directory
492
- if (!resolvedPath.startsWith(resolvedBase)) {
493
- SecurityUtils.logSecurityEvent('Path traversal attempt detected in safeJoin', 'error', {
494
- basePath,
495
- paths,
496
- resolvedPath
497
- });
498
- return false;
499
- }
572
+ if (!resolvedPath.startsWith(resolvedBase)) {
573
+ SecurityUtils.logSecurityEvent('Path traversal attempt detected in safeJoin', 'error', {
574
+ basePath,
575
+ paths,
576
+ resolvedPath,
577
+ source: 'internal'
578
+ });
579
+ return false;
580
+ }
500
581
  return resolvedPath;
501
582
  } catch (error) {
502
- SecurityUtils.logSecurityEvent('Error in safeJoin', 'error', {
503
- basePath,
504
- paths,
505
- error: error.message
506
- });
583
+ SecurityUtils.logSecurityEvent('Error in safeJoin', 'error', {
584
+ basePath,
585
+ paths,
586
+ error: error.message,
587
+ source: 'internal'
588
+ });
507
589
  return false;
508
590
  }
509
591
  }
@@ -551,11 +633,12 @@ static _logging = false;
551
633
  ];
552
634
 
553
635
  // Allow absolute paths that are within the project structure
554
- if (filePath.startsWith('/') || filePath.startsWith('\\')) {
555
- // Allow absolute paths but check for dangerous patterns
556
- const hasDangerousPatterns = dangerousPatterns.slice(1).some(pattern => pattern.test(filePath));
557
- return !hasDangerousPatterns;
558
- }
636
+ if (filePath.startsWith('/') || filePath.startsWith('\\')) {
637
+ // Treat raw Unix-style absolute input as dangerous by default in this helper.
638
+ // `validatePath` can still permit absolute paths if they resolve within basePath.
639
+ const hasDangerousPatterns = dangerousPatterns.slice(1).some(pattern => pattern.test(filePath));
640
+ return !hasDangerousPatterns;
641
+ }
559
642
 
560
643
  return !dangerousPatterns.some(pattern => pattern.test(filePath));
561
644
  }