i18ntk 2.3.7 → 2.3.8
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 +6 -6
- package/package.json +9 -6
- package/utils/config-manager.js +84 -30
- package/utils/config.js +15 -14
- package/utils/i18n-helper.js +35 -20
- package/utils/logger.js +233 -64
- package/utils/security.js +233 -150
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# i18ntk v2.3.
|
|
1
|
+
# i18ntk v2.3.8
|
|
2
2
|
|
|
3
3
|
Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, usage tracking, and translation completion.
|
|
4
4
|
|
|
@@ -9,12 +9,12 @@ Zero-dependency internationalization toolkit for setup, scanning, analysis, vali
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](https://www.npmjs.com/package/i18ntk)
|
|
11
11
|
[](LICENSE)
|
|
12
|
-
[](https://socket.dev/npm/package/i18ntk/overview/2.3.8)
|
|
13
13
|
|
|
14
14
|
## Upgrade Notice
|
|
15
15
|
|
|
16
|
-
Versions earlier than `2.3.
|
|
17
|
-
They are considered unsupported for production use. Upgrade to `2.3.
|
|
16
|
+
Versions earlier than `2.3.8` may contain known stability and security issues.
|
|
17
|
+
They are considered unsupported for production use. Upgrade to `2.3.8` or newer.
|
|
18
18
|
|
|
19
19
|
## What i18ntk Does
|
|
20
20
|
|
|
@@ -151,7 +151,7 @@ Example `.i18ntk-config`:
|
|
|
151
151
|
|
|
152
152
|
```json
|
|
153
153
|
{
|
|
154
|
-
"version": "2.3.
|
|
154
|
+
"version": "2.3.8",
|
|
155
155
|
"sourceDir": "./locales",
|
|
156
156
|
"i18nDir": "./locales",
|
|
157
157
|
"outputDir": "./i18ntk-reports",
|
|
@@ -174,7 +174,7 @@ See [docs/api/CONFIGURATION.md](docs/api/CONFIGURATION.md) for the full configur
|
|
|
174
174
|
- [Runtime API Guide](docs/runtime.md)
|
|
175
175
|
- [Scanner Guide](docs/scanner-guide.md)
|
|
176
176
|
- [Environment Variables](docs/environment-variables.md)
|
|
177
|
-
- [Migration Guide v2.3.
|
|
177
|
+
- [Migration Guide v2.3.8](docs/migration-guide-v2.3.8.md)
|
|
178
178
|
- [Optimization Prompt](docs/development/package-optimization-prompt.md)
|
|
179
179
|
|
|
180
180
|
## Code of Conduct
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18ntk",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.8",
|
|
4
4
|
"description": "🚀 The fastest internationalization toolkit with 97% performance boost! Zero-dependency, enterprise-grade internationalization for React, Vue, Angular, Python, Java, PHP & more. Features PIN protection, auto framework detection, 7+ UI languages, and comprehensive translation management. Perfect for startups to enterprises.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"languages:select": "node settings/settings-cli.js",
|
|
214
214
|
"languages:list": "node settings/settings-cli.js --list-languages",
|
|
215
215
|
"languages:status": "node settings/settings-cli.js --language-status",
|
|
216
|
-
"lint:locales": "node scripts/lint-locales.js"
|
|
216
|
+
"lint:locales": "node scripts/lint-locales.js"
|
|
217
217
|
},
|
|
218
218
|
"engines": {
|
|
219
219
|
"node": ">=16.0.0",
|
|
@@ -224,14 +224,17 @@
|
|
|
224
224
|
},
|
|
225
225
|
"preferGlobal": true,
|
|
226
226
|
"versionInfo": {
|
|
227
|
-
"version": "2.3.
|
|
228
|
-
"releaseDate": "
|
|
229
|
-
"lastUpdated": "
|
|
227
|
+
"version": "2.3.8",
|
|
228
|
+
"releaseDate": "13/04/2026",
|
|
229
|
+
"lastUpdated": "13/04/2026",
|
|
230
230
|
"maintainer": "Vlad Noskov",
|
|
231
231
|
"changelog": "./CHANGELOG.md",
|
|
232
232
|
"documentation": "./README.md",
|
|
233
233
|
"apiReference": "./docs/api/API_REFERENCE.md",
|
|
234
234
|
"majorChanges": [
|
|
235
|
+
"LOGGING: Introduced centralized structured logger with silent-by-default production behavior and DEBUG_MODE/JSON_LOG toggles.",
|
|
236
|
+
"SECURITY: Added internal path whitelist detection to prevent false-positive traversal warnings for package/project internals.",
|
|
237
|
+
"I18N: Added missing-key warning TTL cache to eliminate repeated translation-key spam during builds.",
|
|
235
238
|
"HOTFIX: Removed deprecated package-path fallback that caused production build warnings for non-exported subpaths.",
|
|
236
239
|
"CRITICAL FIX: Resolved sizing and usage-analysis regressions in v2 command flow.",
|
|
237
240
|
"PACKAGING: Reduced publish footprint by removing internal development scripts and legacy fixed-file artifacts.",
|
|
@@ -265,7 +268,7 @@
|
|
|
265
268
|
"spring-boot": ">=2.5.0",
|
|
266
269
|
"laravel": ">=8.0.0"
|
|
267
270
|
},
|
|
268
|
-
"supportPolicy": "Versions earlier than 2.3.
|
|
271
|
+
"supportPolicy": "Versions earlier than 2.3.8 may be unstable or insecure. Upgrade to 2.3.8 or newer."
|
|
269
272
|
},
|
|
270
273
|
"_comment": "This package is zero-dependency and uses only native Node.js modules"
|
|
271
274
|
}
|
package/utils/config-manager.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
const SecurityUtils = require('./security');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const SecurityUtils = require('./security');
|
|
6
|
+
const { logger } = require('./logger');
|
|
6
7
|
|
|
7
8
|
// Determine package directory and user project root
|
|
8
9
|
const packageDir = path.resolve(__dirname, '..');
|
|
@@ -19,7 +20,55 @@ const CONFIG_LOCK_PATH = `${PROJECT_CONFIG_PATH}.lock`;
|
|
|
19
20
|
const CONFIG_LOCK_TIMEOUT_MS = 5000;
|
|
20
21
|
const CONFIG_LOCK_STALE_MS = 15000;
|
|
21
22
|
const CONFIG_LOCK_RETRY_MS = 50;
|
|
22
|
-
let autosaveDisabledWarned = false;
|
|
23
|
+
let autosaveDisabledWarned = false;
|
|
24
|
+
let defaultConfigNoticeShown = false;
|
|
25
|
+
let configFallbackNoticeShown = false;
|
|
26
|
+
|
|
27
|
+
function logInfo(message, details) {
|
|
28
|
+
logger.info(message, details);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function logWarn(message, details) {
|
|
32
|
+
logger.warn(message, details);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function logError(message, details) {
|
|
36
|
+
logger.error(message, details);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function notifyDefaultConfig(reason, error) {
|
|
40
|
+
if (defaultConfigNoticeShown) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
defaultConfigNoticeShown = true;
|
|
44
|
+
|
|
45
|
+
logger.info('Using default configuration (reason: configuration error)', { reason });
|
|
46
|
+
if (logger.isDebugMode() && error) {
|
|
47
|
+
logger.debug(`Default configuration reason details: ${error.message}`, {
|
|
48
|
+
stack: error.stack
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function notifyConfigFallback(error) {
|
|
54
|
+
if (!configFallbackNoticeShown) {
|
|
55
|
+
configFallbackNoticeShown = true;
|
|
56
|
+
logger.info('Using default configuration (reason: configuration error)', {
|
|
57
|
+
reason: 'configuration error'
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.recordFirstError('config:load-fallback', {
|
|
62
|
+
message: error && error.message ? error.message : 'Unknown configuration error',
|
|
63
|
+
stack: error && error.stack ? error.stack : null
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (logger.isDebugMode() && error) {
|
|
67
|
+
logger.debug(`Configuration load fallback details: ${error.message}`, {
|
|
68
|
+
stack: error.stack
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
23
72
|
|
|
24
73
|
// Setup tracking file
|
|
25
74
|
const SETUP_COMPLETED_FILE = path.join(PROJECT_SETTINGS_DIR, 'setup.json');
|
|
@@ -381,7 +430,7 @@ function tryReadJson(filePath) {
|
|
|
381
430
|
|
|
382
431
|
const data = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
|
|
383
432
|
if (!data || data.trim() === '') {
|
|
384
|
-
|
|
433
|
+
logWarn(`[i18ntk] Warning: Empty or invalid JSON file at ${filePath}`);
|
|
385
434
|
return null;
|
|
386
435
|
}
|
|
387
436
|
|
|
@@ -390,18 +439,18 @@ function tryReadJson(filePath) {
|
|
|
390
439
|
return parsed;
|
|
391
440
|
}
|
|
392
441
|
|
|
393
|
-
|
|
442
|
+
logError(`[i18ntk] Error parsing JSON from ${filePath}: Invalid JSON content`);
|
|
394
443
|
// Create a backup of the corrupted file
|
|
395
444
|
const backupPath = `${filePath}.corrupted-${Date.now()}.bak`;
|
|
396
445
|
try {
|
|
397
446
|
SecurityUtils.safeWriteFileSync(backupPath, data, path.dirname(backupPath), 'utf8');
|
|
398
|
-
|
|
447
|
+
logWarn(`[i18ntk] Created backup of corrupted config at ${backupPath}`);
|
|
399
448
|
} catch (backupError) {
|
|
400
|
-
|
|
449
|
+
logError(`[i18ntk] Failed to create backup of corrupted config: ${backupError.message}`);
|
|
401
450
|
}
|
|
402
451
|
return null;
|
|
403
452
|
} catch (error) {
|
|
404
|
-
|
|
453
|
+
logError(`[i18ntk] Error reading config file at ${filePath}: ${error.message}`);
|
|
405
454
|
return null;
|
|
406
455
|
}
|
|
407
456
|
}
|
|
@@ -420,11 +469,11 @@ async function migrateLegacyIfNeeded(baseCfg) {
|
|
|
420
469
|
// Best-effort removal of legacy file to prevent future use
|
|
421
470
|
try { fs.unlinkSync(LEGACY_CONFIG_PATH); } catch (_) {}
|
|
422
471
|
// Deprecation notice
|
|
423
|
-
|
|
472
|
+
logWarn('[i18ntk] Deprecated config location detected (~/.i18ntk). Configuration was migrated to project .i18ntk-config.');
|
|
424
473
|
return merged;
|
|
425
474
|
} catch (_) {
|
|
426
475
|
// If write fails, fall back to in-memory config without deleting legacy
|
|
427
|
-
|
|
476
|
+
logWarn('[i18ntk] Deprecated config location detected (~/.i18ntk). Using migrated settings in memory; failed to persist to .i18ntk-config.');
|
|
428
477
|
return merged;
|
|
429
478
|
}
|
|
430
479
|
}
|
|
@@ -445,7 +494,7 @@ function loadConfig() {
|
|
|
445
494
|
|
|
446
495
|
// Prevent concurrent loading
|
|
447
496
|
if (configLoadInProgress) {
|
|
448
|
-
|
|
497
|
+
logWarn('[i18ntk] Configuration loading already in progress, returning defaults');
|
|
449
498
|
resetRecursionGuard();
|
|
450
499
|
return clone(DEFAULT_CONFIG);
|
|
451
500
|
}
|
|
@@ -472,7 +521,7 @@ function loadConfig() {
|
|
|
472
521
|
// Attempt to migrate to project settings
|
|
473
522
|
// Ignore migration errors; we still return merged cfg in memory
|
|
474
523
|
// eslint-disable-next-line no-unused-vars
|
|
475
|
-
|
|
524
|
+
logWarn('[i18ntk] Detected legacy config at ~/.i18ntk. Migrating to project settings directory...');
|
|
476
525
|
const _ = (async () => { await migrateLegacyIfNeeded(DEFAULT_CONFIG); })();
|
|
477
526
|
}
|
|
478
527
|
}
|
|
@@ -481,7 +530,8 @@ function loadConfig() {
|
|
|
481
530
|
currentConfig = cfg;
|
|
482
531
|
return currentConfig;
|
|
483
532
|
} catch (error) {
|
|
484
|
-
|
|
533
|
+
logError('[i18ntk] Error in loadConfig', { error: error.message });
|
|
534
|
+
notifyConfigFallback(error);
|
|
485
535
|
currentConfig = clone(DEFAULT_CONFIG);
|
|
486
536
|
return currentConfig;
|
|
487
537
|
} finally {
|
|
@@ -498,7 +548,7 @@ async function saveConfig(cfg = currentConfig) {
|
|
|
498
548
|
currentConfig = cfg;
|
|
499
549
|
if (!autosaveDisabledWarned) {
|
|
500
550
|
autosaveDisabledWarned = true;
|
|
501
|
-
|
|
551
|
+
logWarn('[i18ntk] Autosave disabled by I18NTK_DISABLE_AUTOSAVE. Keeping configuration in memory only.');
|
|
502
552
|
}
|
|
503
553
|
return false;
|
|
504
554
|
}
|
|
@@ -540,7 +590,7 @@ async function saveConfig(cfg = currentConfig) {
|
|
|
540
590
|
currentConfig = cfg;
|
|
541
591
|
return true;
|
|
542
592
|
} catch (error) {
|
|
543
|
-
|
|
593
|
+
logError('[i18ntk] Error saving configuration', { error: error.message });
|
|
544
594
|
return false;
|
|
545
595
|
} finally {
|
|
546
596
|
if (releaseLock) {
|
|
@@ -593,7 +643,7 @@ function getConfig() {
|
|
|
593
643
|
|
|
594
644
|
// Check for legacy config for migration
|
|
595
645
|
if (SecurityUtils.safeExistsSync(LEGACY_CONFIG_PATH)) {
|
|
596
|
-
|
|
646
|
+
logInfo('Migrating legacy configuration');
|
|
597
647
|
const legacyRaw = SecurityUtils.safeReadFileSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH), 'utf8');
|
|
598
648
|
const legacyConfig = SecurityUtils.safeParseJSON(legacyRaw);
|
|
599
649
|
if (!legacyConfig || typeof legacyConfig !== 'object') {
|
|
@@ -601,7 +651,7 @@ function getConfig() {
|
|
|
601
651
|
}
|
|
602
652
|
const migratedConfig = { ...DEFAULT_CONFIG, ...legacyConfig };
|
|
603
653
|
saveConfig(migratedConfig).catch((err) => {
|
|
604
|
-
|
|
654
|
+
logWarn('[i18ntk] Warning: failed to persist migrated configuration', { error: err.message });
|
|
605
655
|
});
|
|
606
656
|
currentConfig = migratedConfig;
|
|
607
657
|
|
|
@@ -619,18 +669,18 @@ function getConfig() {
|
|
|
619
669
|
}
|
|
620
670
|
|
|
621
671
|
// Use package defaults for new installation
|
|
622
|
-
|
|
623
|
-
saveConfig(DEFAULT_CONFIG).catch((err) => {
|
|
624
|
-
|
|
625
|
-
});
|
|
626
|
-
currentConfig = DEFAULT_CONFIG;
|
|
627
|
-
return resolvePaths(DEFAULT_CONFIG);
|
|
628
|
-
|
|
629
|
-
} catch (error) {
|
|
630
|
-
|
|
631
|
-
currentConfig = DEFAULT_CONFIG;
|
|
632
|
-
return resolvePaths(DEFAULT_CONFIG);
|
|
633
|
-
}
|
|
672
|
+
notifyDefaultConfig('default initialization');
|
|
673
|
+
saveConfig(DEFAULT_CONFIG).catch((err) => {
|
|
674
|
+
logWarn('[i18ntk] Warning: failed to persist default configuration', { error: err.message });
|
|
675
|
+
});
|
|
676
|
+
currentConfig = DEFAULT_CONFIG;
|
|
677
|
+
return resolvePaths(DEFAULT_CONFIG);
|
|
678
|
+
|
|
679
|
+
} catch (error) {
|
|
680
|
+
notifyConfigFallback(error);
|
|
681
|
+
currentConfig = DEFAULT_CONFIG;
|
|
682
|
+
return resolvePaths(DEFAULT_CONFIG);
|
|
683
|
+
}
|
|
634
684
|
}
|
|
635
685
|
|
|
636
686
|
async function setConfig(cfg) {
|
|
@@ -693,3 +743,7 @@ module.exports = {
|
|
|
693
743
|
toRelative,
|
|
694
744
|
normalizePathValue,
|
|
695
745
|
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
|
package/utils/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const SecurityUtils = require('./security');
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const SecurityUtils = require('./security');
|
|
4
|
+
const { logger } = require('./logger');
|
|
4
5
|
|
|
5
6
|
const settingsManager = require('../settings/settings-manager');
|
|
6
7
|
const CONFIG_FILE = 'i18ntk-config.json';
|
|
@@ -66,11 +67,11 @@ function loadConfig(cwd = settingsManager.configDir) {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
throw new Error('Invalid configuration format');
|
|
69
|
-
} catch (error) {
|
|
70
|
-
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logger.debug(`Error loading config: ${error.message}`, { stack: error.stack });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* Saves configuration to file
|
|
@@ -96,11 +97,11 @@ function saveConfig(config, cwd = settingsManager.configDir) {
|
|
|
96
97
|
);
|
|
97
98
|
|
|
98
99
|
return true;
|
|
99
|
-
} catch (error) {
|
|
100
|
-
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
logger.error(`Error saving config: ${error.message}`);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
107
|
* Ensures default values for configuration
|
|
@@ -127,4 +128,4 @@ module.exports = {
|
|
|
127
128
|
saveConfig,
|
|
128
129
|
ensureConfigDefaults,
|
|
129
130
|
validatePath // Exported for testing
|
|
130
|
-
};
|
|
131
|
+
};
|
package/utils/i18n-helper.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// utils/i18n-helper.js
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { logger } = require('./logger');
|
|
4
5
|
|
|
5
6
|
// Lazy load SecurityUtils to prevent circular dependencies
|
|
6
7
|
let securityUtils;
|
|
@@ -200,10 +201,21 @@ function findLocaleFilesAllDirs(lang, preferredDir) {
|
|
|
200
201
|
return files;
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
let translations = {};
|
|
204
|
-
let currentLanguage = 'en';
|
|
205
|
-
let isInitialized = false;
|
|
206
|
-
const
|
|
204
|
+
let translations = {};
|
|
205
|
+
let currentLanguage = 'en';
|
|
206
|
+
let isInitialized = false;
|
|
207
|
+
const missingKeyCache = new Map();
|
|
208
|
+
const missingKeyTtlMs = 5 * 60 * 1000;
|
|
209
|
+
|
|
210
|
+
function shouldReportMissingKey(key) {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
const expiresAt = missingKeyCache.get(key) || 0;
|
|
213
|
+
if (expiresAt > now) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
missingKeyCache.set(key, now + missingKeyTtlMs);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
207
219
|
|
|
208
220
|
function loadTranslations(language) {
|
|
209
221
|
const cfg = safeRequireConfig();
|
|
@@ -286,9 +298,11 @@ function loadTranslations(language) {
|
|
|
286
298
|
currentLanguage = 'en';
|
|
287
299
|
isInitialized = true;
|
|
288
300
|
|
|
289
|
-
if (loadErrors.length > 0) {
|
|
290
|
-
|
|
291
|
-
|
|
301
|
+
if (loadErrors.length > 0) {
|
|
302
|
+
logger.warn('No valid UI locale files found. Using built-in English strings.', {
|
|
303
|
+
errorCount: loadErrors.length
|
|
304
|
+
});
|
|
305
|
+
}
|
|
292
306
|
|
|
293
307
|
return translations;
|
|
294
308
|
}
|
|
@@ -337,23 +351,24 @@ function t(key, params = {}) {
|
|
|
337
351
|
}
|
|
338
352
|
}
|
|
339
353
|
|
|
340
|
-
if (typeof value === 'undefined') {
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
354
|
+
if (typeof value === 'undefined') {
|
|
355
|
+
if (shouldReportMissingKey(key)) {
|
|
356
|
+
logger.logMissingTranslationKey(key, 'Configuration error');
|
|
357
|
+
}
|
|
358
|
+
return key;
|
|
359
|
+
}
|
|
347
360
|
|
|
348
361
|
// If we found a string, interpolate parameters
|
|
349
362
|
if (typeof value === 'string') {
|
|
350
363
|
return interpolateParams(value, params);
|
|
351
364
|
}
|
|
352
365
|
|
|
353
|
-
// Return the key if the final value is not a string
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
366
|
+
// Return the key if the final value is not a string
|
|
367
|
+
if (shouldReportMissingKey(`${key}:non-string`)) {
|
|
368
|
+
logger.warn(`Translation key does not resolve to a string: ${key}`);
|
|
369
|
+
}
|
|
370
|
+
return key;
|
|
371
|
+
}
|
|
357
372
|
|
|
358
373
|
/**
|
|
359
374
|
* Interpolate parameters into a translation string
|
package/utils/logger.js
CHANGED
|
@@ -1,64 +1,233 @@
|
|
|
1
|
-
const colors = require('./colors-new');
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
const colors = require('./colors-new');
|
|
2
|
+
|
|
3
|
+
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4
|
+
const DEFAULT_INTERNAL_PREFIXES = ['[BUILD]', '[WORKERS]', '[I18N]', '[SECURITY]', '[SUCCESS]', '[WARN]', '[ERROR]', '[INFO]', '[DEBUG]'];
|
|
5
|
+
const FIRST_ERROR_CONTEXT = new Map();
|
|
6
|
+
|
|
7
|
+
function asBoolean(value) {
|
|
8
|
+
return String(value || '').trim().toLowerCase() === 'true';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveLevel() {
|
|
12
|
+
const debugMode = asBoolean(process.env.DEBUG_MODE);
|
|
13
|
+
const configured = String(process.env.I18NTK_LOG_LEVEL || '').trim().toLowerCase();
|
|
14
|
+
if (configured && Object.prototype.hasOwnProperty.call(LEVELS, configured)) {
|
|
15
|
+
return configured;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (debugMode) {
|
|
19
|
+
return 'debug';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Silent-by-default in production-like builds.
|
|
23
|
+
if (process.env.NODE_ENV === 'production' || asBoolean(process.env.CI)) {
|
|
24
|
+
return 'error';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return 'warn';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shouldLog(level) {
|
|
31
|
+
const active = resolveLevel();
|
|
32
|
+
return LEVELS[level] <= LEVELS[active];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizePrefix(prefix, fallback) {
|
|
36
|
+
const value = String(prefix || fallback || '[INFO]').trim();
|
|
37
|
+
if (/^\[[^\]]+\]$/.test(value)) {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
return `[${value.replace(/^[\[]|[\]]$/g, '').toUpperCase()}]`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function write(level, message, options = {}) {
|
|
44
|
+
if (!shouldLog(level)) return;
|
|
45
|
+
|
|
46
|
+
const jsonMode = asBoolean(process.env.JSON_LOG);
|
|
47
|
+
const prefix = normalizePrefix(options.prefix, `[${level.toUpperCase()}]`);
|
|
48
|
+
const details = options.details && typeof options.details === 'object' ? options.details : undefined;
|
|
49
|
+
const text = String(message || '').trim();
|
|
50
|
+
|
|
51
|
+
if (jsonMode) {
|
|
52
|
+
const payload = {
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
level,
|
|
55
|
+
prefix,
|
|
56
|
+
message: text,
|
|
57
|
+
...(details ? { details } : {})
|
|
58
|
+
};
|
|
59
|
+
const line = JSON.stringify(payload);
|
|
60
|
+
if (level === 'error' || level === 'warn') {
|
|
61
|
+
process.stderr.write(`${line}\n`);
|
|
62
|
+
} else {
|
|
63
|
+
process.stdout.write(`${line}\n`);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const line = `${prefix} ${text}`;
|
|
69
|
+
if (level === 'error') {
|
|
70
|
+
process.stderr.write(`${colors.red(line)}\n`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (level === 'warn') {
|
|
74
|
+
process.stderr.write(`${colors.yellow(line)}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (level === 'debug') {
|
|
78
|
+
process.stdout.write(`${colors.gray(line)}\n`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.stdout.write(`${line}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatDuration(ms) {
|
|
86
|
+
if (!Number.isFinite(ms) || ms < 0) return 'instant';
|
|
87
|
+
if (ms < 50) return 'instant';
|
|
88
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
89
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
90
|
+
const minutes = Math.floor(ms / 60000);
|
|
91
|
+
const seconds = Math.round((ms % 60000) / 1000);
|
|
92
|
+
return `${minutes}m${seconds}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function recordFirstError(key, context) {
|
|
96
|
+
if (!key || FIRST_ERROR_CONTEXT.has(key)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
FIRST_ERROR_CONTEXT.set(key, {
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
...context
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getFirstErrorContext(key) {
|
|
107
|
+
return FIRST_ERROR_CONTEXT.get(key) || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function flushErrorContexts() {
|
|
111
|
+
const entries = Array.from(FIRST_ERROR_CONTEXT.entries()).map(([key, value]) => ({ key, ...value }));
|
|
112
|
+
FIRST_ERROR_CONTEXT.clear();
|
|
113
|
+
return entries;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function emitErrorContextSummary({ force = false } = {}) {
|
|
117
|
+
const contexts = Array.from(FIRST_ERROR_CONTEXT.entries());
|
|
118
|
+
if (!contexts.length) return;
|
|
119
|
+
if (!force && !asBoolean(process.env.DEBUG_MODE)) return;
|
|
120
|
+
|
|
121
|
+
for (const [key, context] of contexts) {
|
|
122
|
+
write('debug', `First error context (${key})`, { prefix: '[ERROR]', details: context });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function logMissingTranslationKey(key, fallback = 'Configuration error') {
|
|
127
|
+
write('warn', `Missing key: ${key} (fallback: '${fallback}')`, { prefix: '[I18N]' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createBuildProgressReporter(totalPages) {
|
|
131
|
+
const safeTotal = Math.max(1, Number(totalPages) || 1);
|
|
132
|
+
let lastBucket = 0;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
update(completedPages) {
|
|
136
|
+
const completed = Math.max(0, Math.min(safeTotal, Number(completedPages) || 0));
|
|
137
|
+
const percent = Math.floor((completed / safeTotal) * 100);
|
|
138
|
+
const bucket = Math.floor(percent / 10) * 10;
|
|
139
|
+
if (bucket <= lastBucket || bucket === 0) return;
|
|
140
|
+
lastBucket = bucket;
|
|
141
|
+
|
|
142
|
+
const pct = `${String(bucket).padStart(3, ' ')}%`;
|
|
143
|
+
write('info', `${pct} (${completed}/${safeTotal} pages)`, { prefix: '[BUILD]' });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildSuccessSummary({ durationMs, pages, warnings = 0 }) {
|
|
149
|
+
const duration = formatDuration(durationMs);
|
|
150
|
+
write('info', `Build completed in ${duration} (${pages} pages, ${warnings} warnings)`, { prefix: '[SUCCESS]' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createWorkerPoolMonitor(activeWorkers = 0) {
|
|
154
|
+
const startedAt = Date.now();
|
|
155
|
+
let tasks = 0;
|
|
156
|
+
let totalTaskDurationMs = 0;
|
|
157
|
+
let workers = Math.max(0, Number(activeWorkers) || 0);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
setActive(count) {
|
|
161
|
+
workers = Math.max(0, Number(count) || 0);
|
|
162
|
+
},
|
|
163
|
+
recordTask(durationMs) {
|
|
164
|
+
tasks += 1;
|
|
165
|
+
totalTaskDurationMs += Math.max(0, Number(durationMs) || 0);
|
|
166
|
+
},
|
|
167
|
+
report() {
|
|
168
|
+
const avg = tasks > 0 ? (totalTaskDurationMs / tasks) / 1000 : 0;
|
|
169
|
+
write('info', `${workers} active (avg ${avg.toFixed(1)}s/task)`, { prefix: '[WORKERS]' });
|
|
170
|
+
return {
|
|
171
|
+
workers,
|
|
172
|
+
tasks,
|
|
173
|
+
avgSecondsPerTask: Number(avg.toFixed(2)),
|
|
174
|
+
elapsed: formatDuration(Date.now() - startedAt)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const logger = {
|
|
181
|
+
log(message, color = null) {
|
|
182
|
+
const output = typeof color === 'function' ? color(String(message)) : String(message);
|
|
183
|
+
write('info', output, { prefix: '[INFO]' });
|
|
184
|
+
},
|
|
185
|
+
info(message, details) {
|
|
186
|
+
write('info', String(message), { prefix: '[INFO]', details });
|
|
187
|
+
},
|
|
188
|
+
warn(message, details) {
|
|
189
|
+
write('warn', String(message), { prefix: '[WARN]', details });
|
|
190
|
+
},
|
|
191
|
+
error(message, details) {
|
|
192
|
+
write('error', String(message), { prefix: '[ERROR]', details });
|
|
193
|
+
},
|
|
194
|
+
debug(message, details) {
|
|
195
|
+
write('debug', String(message), { prefix: '[DEBUG]', details });
|
|
196
|
+
},
|
|
197
|
+
success(message, details) {
|
|
198
|
+
write('info', String(message), { prefix: '[SUCCESS]', details });
|
|
199
|
+
},
|
|
200
|
+
security(level, message, details) {
|
|
201
|
+
write(level, String(message), { prefix: '[SECURITY]', details });
|
|
202
|
+
},
|
|
203
|
+
build(message, details) {
|
|
204
|
+
write('info', String(message), { prefix: '[BUILD]', details });
|
|
205
|
+
},
|
|
206
|
+
isDebugMode() {
|
|
207
|
+
return asBoolean(process.env.DEBUG_MODE) || resolveLevel() === 'debug';
|
|
208
|
+
},
|
|
209
|
+
shouldLog,
|
|
210
|
+
formatDuration,
|
|
211
|
+
recordFirstError,
|
|
212
|
+
getFirstErrorContext,
|
|
213
|
+
flushErrorContexts,
|
|
214
|
+
emitErrorContextSummary,
|
|
215
|
+
buildSuccessSummary,
|
|
216
|
+
logMissingTranslationKey,
|
|
217
|
+
createBuildProgressReporter,
|
|
218
|
+
createWorkerPoolMonitor,
|
|
219
|
+
// Keep compatibility for callers that rely on known prefixes.
|
|
220
|
+
KNOWN_PREFIXES: DEFAULT_INTERNAL_PREFIXES
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
colors,
|
|
225
|
+
logger,
|
|
226
|
+
formatDuration,
|
|
227
|
+
createBuildProgressReporter,
|
|
228
|
+
createWorkerPoolMonitor,
|
|
229
|
+
recordFirstError,
|
|
230
|
+
getFirstErrorContext,
|
|
231
|
+
flushErrorContexts,
|
|
232
|
+
emitErrorContextSummary
|
|
233
|
+
};
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
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
|
}
|