i18ntk 2.3.5 → 2.3.7
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 +11 -11
- package/package.json +5 -8
- package/settings/settings-manager.js +12 -11
- package/utils/config-manager.js +200 -194
- package/utils/security.js +95 -38
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# i18ntk v2.3.
|
|
1
|
+
# i18ntk v2.3.7
|
|
2
2
|
|
|
3
3
|
Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, usage tracking, and translation completion.
|
|
4
4
|
|
|
@@ -9,16 +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.7)
|
|
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.
|
|
18
|
-
The CLI can check npm registry metadata and warn when your installed version is out of date.
|
|
19
|
-
Set `I18NTK_ENABLE_UPDATE_CHECK=true` to enable this behavior.
|
|
20
|
-
Set `I18NTK_DISABLE_UPDATE_CHECK=true` to force-disable it in restricted/offline environments.
|
|
21
|
-
Set `I18NTK_DISABLE_AUTOSAVE=1` in server/runtime environments to keep config in memory and skip disk writes.
|
|
16
|
+
Versions earlier than `2.3.7` may contain known stability and security issues.
|
|
17
|
+
They are considered unsupported for production use. Upgrade to `2.3.7` or newer.
|
|
22
18
|
|
|
23
19
|
## What i18ntk Does
|
|
24
20
|
|
|
@@ -155,7 +151,7 @@ Example `.i18ntk-config`:
|
|
|
155
151
|
|
|
156
152
|
```json
|
|
157
153
|
{
|
|
158
|
-
"version": "2.3.
|
|
154
|
+
"version": "2.3.6",
|
|
159
155
|
"sourceDir": "./locales",
|
|
160
156
|
"i18nDir": "./locales",
|
|
161
157
|
"outputDir": "./i18ntk-reports",
|
|
@@ -178,9 +174,13 @@ See [docs/api/CONFIGURATION.md](docs/api/CONFIGURATION.md) for the full configur
|
|
|
178
174
|
- [Runtime API Guide](docs/runtime.md)
|
|
179
175
|
- [Scanner Guide](docs/scanner-guide.md)
|
|
180
176
|
- [Environment Variables](docs/environment-variables.md)
|
|
181
|
-
- [Migration Guide v2.3.5](docs/migration-guide-v2.3.
|
|
177
|
+
- [Migration Guide v2.3.5](docs/migration-guide-v2.3.6.md)
|
|
182
178
|
- [Optimization Prompt](docs/development/package-optimization-prompt.md)
|
|
183
179
|
|
|
180
|
+
## Code of Conduct
|
|
181
|
+
|
|
182
|
+
We are committed to providing a friendly, safe and welcoming environment for all. Please read and respect our [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
183
|
+
|
|
184
184
|
## License
|
|
185
185
|
|
|
186
|
-
MIT
|
|
186
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18ntk",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.7",
|
|
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",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
},
|
|
113
113
|
"license": "MIT",
|
|
114
114
|
"author": {
|
|
115
|
-
"name": "
|
|
115
|
+
"name": "Vlad Noskov",
|
|
116
116
|
"url": "https://github.com/vladnoskv"
|
|
117
117
|
},
|
|
118
118
|
"type": "commonjs",
|
|
@@ -213,10 +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"
|
|
217
|
-
"deprecate:versions": "node scripts/deprecate-versions.js",
|
|
218
|
-
"deprecate:dry-run": "node scripts/deprecate-versions.js --dry-run",
|
|
219
|
-
"deprecate:verify": "node scripts/verify-deprecations.js"
|
|
216
|
+
"lint:locales": "node scripts/lint-locales.js"
|
|
220
217
|
},
|
|
221
218
|
"engines": {
|
|
222
219
|
"node": ">=16.0.0",
|
|
@@ -227,7 +224,7 @@
|
|
|
227
224
|
},
|
|
228
225
|
"preferGlobal": true,
|
|
229
226
|
"versionInfo": {
|
|
230
|
-
"version": "2.3.
|
|
227
|
+
"version": "2.3.7",
|
|
231
228
|
"releaseDate": "12/04/2026",
|
|
232
229
|
"lastUpdated": "12/04/2026",
|
|
233
230
|
"maintainer": "Vlad Noskov",
|
|
@@ -268,7 +265,7 @@
|
|
|
268
265
|
"spring-boot": ">=2.5.0",
|
|
269
266
|
"laravel": ">=8.0.0"
|
|
270
267
|
},
|
|
271
|
-
"supportPolicy": "Versions earlier than 2.3.
|
|
268
|
+
"supportPolicy": "Versions earlier than 2.3.7 may be unstable or insecure. Upgrade to 2.3.6 or newer."
|
|
272
269
|
},
|
|
273
270
|
"_comment": "This package is zero-dependency and uses only native Node.js modules"
|
|
274
271
|
}
|
|
@@ -6,14 +6,15 @@ const SecurityUtils = require('../utils/security');
|
|
|
6
6
|
|
|
7
7
|
class SettingsManager {
|
|
8
8
|
constructor() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// Use centralized .i18ntk-settings file as single source of truth
|
|
10
|
+
this.configDir = path.resolve(__dirname, '..');
|
|
11
|
+
const projectRoot = path.resolve(process.cwd());
|
|
12
|
+
this.configFile = SecurityUtils.safeJoin(projectRoot, '.i18ntk-settings') || path.join(projectRoot, '.i18ntk-settings');
|
|
13
|
+
this.backupDir = SecurityUtils.safeJoin(projectRoot, 'i18ntk-backups') || path.join(projectRoot, 'i18ntk-backups');
|
|
13
14
|
this.saveTimeout = null;
|
|
14
15
|
|
|
15
16
|
this.defaultConfig = {
|
|
16
|
-
"version": "
|
|
17
|
+
"version": "2.3.6",
|
|
17
18
|
"language": "en",
|
|
18
19
|
"uiLanguage": "en",
|
|
19
20
|
"theme": "dark",
|
|
@@ -573,8 +574,8 @@ class SettingsManager {
|
|
|
573
574
|
|
|
574
575
|
// 2. Remove actual configuration files used by the system
|
|
575
576
|
const packageDir = path.resolve(__dirname, '..');
|
|
576
|
-
const projectRoot = process.cwd();
|
|
577
|
-
const settingsDir =
|
|
577
|
+
const projectRoot = path.resolve(process.cwd());
|
|
578
|
+
const settingsDir = SecurityUtils.safeJoin(packageDir, 'settings');
|
|
578
579
|
|
|
579
580
|
// Main configuration file
|
|
580
581
|
const mainConfigPath = path.join(settingsDir, 'i18ntk-config.json');
|
|
@@ -611,7 +612,7 @@ class SettingsManager {
|
|
|
611
612
|
path.join(packageDir, '.i18n-admin-config.json'),
|
|
612
613
|
path.join(settingsDir, '.i18n-admin-config.json'),
|
|
613
614
|
path.join(settingsDir, 'admin-config.json'),
|
|
614
|
-
path.join(projectRoot, '.i18n-admin-config.json')
|
|
615
|
+
SecurityUtils.safeJoin(projectRoot, '.i18n-admin-config.json') || path.join(projectRoot, '.i18n-admin-config.json')
|
|
615
616
|
];
|
|
616
617
|
|
|
617
618
|
for (const adminConfigPath of adminConfigPaths) {
|
|
@@ -653,11 +654,11 @@ class SettingsManager {
|
|
|
653
654
|
path.join(settingsDir, '.temp-config.json'),
|
|
654
655
|
path.join(settingsDir, '.last-config.json'),
|
|
655
656
|
path.join(settingsDir, '.lock'),
|
|
656
|
-
path.join(settingsDir, 'i18ntk-config.
|
|
657
|
+
path.join(settingsDir, '.i18ntk-config.temp-*'),
|
|
657
658
|
path.join(settingsDir, 'settings.lock'),
|
|
658
659
|
path.join(packageDir, '.env-config.json'),
|
|
659
660
|
path.join(packageDir, '.temp-config.json'),
|
|
660
|
-
path.join(packageDir, 'config.
|
|
661
|
+
path.join(packageDir, 'config.temp-*'),
|
|
661
662
|
path.join(packageDir, '.lock')
|
|
662
663
|
];
|
|
663
664
|
|
|
@@ -1002,4 +1003,4 @@ class SettingsManager {
|
|
|
1002
1003
|
}
|
|
1003
1004
|
|
|
1004
1005
|
module.exports = SettingsManager;
|
|
1005
|
-
|
|
1006
|
+
|
package/utils/config-manager.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
const SecurityUtils = require('./security');
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const SecurityUtils = require('./security');
|
|
6
6
|
|
|
7
7
|
// Determine package directory and user project root
|
|
8
8
|
const packageDir = path.resolve(__dirname, '..');
|
|
9
|
-
const userProjectRoot = process.cwd();
|
|
9
|
+
const userProjectRoot = path.resolve(process.cwd());
|
|
10
10
|
|
|
11
11
|
// Always use current working directory for settings to support test environments
|
|
12
12
|
// This ensures config works correctly when tests change the working directory
|
|
13
|
-
const PROJECT_CONFIG_PATH =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
13
|
+
const PROJECT_CONFIG_PATH = SecurityUtils.safeJoin(userProjectRoot, '.i18ntk-config');
|
|
14
|
+
if (!PROJECT_CONFIG_PATH) {
|
|
15
|
+
throw new Error('Invalid project config path - potential path traversal attempt');
|
|
16
|
+
}
|
|
17
|
+
const PROJECT_SETTINGS_DIR = path.dirname(PROJECT_CONFIG_PATH);
|
|
18
|
+
const CONFIG_LOCK_PATH = `${PROJECT_CONFIG_PATH}.lock`;
|
|
19
|
+
const CONFIG_LOCK_TIMEOUT_MS = 5000;
|
|
20
|
+
const CONFIG_LOCK_STALE_MS = 15000;
|
|
21
|
+
const CONFIG_LOCK_RETRY_MS = 50;
|
|
22
|
+
let autosaveDisabledWarned = false;
|
|
20
23
|
|
|
21
24
|
// Setup tracking file
|
|
22
25
|
const SETUP_COMPLETED_FILE = path.join(PROJECT_SETTINGS_DIR, 'setup.json');
|
|
@@ -175,14 +178,14 @@ const DEFAULT_CONFIG = {
|
|
|
175
178
|
"memoryPooling": true,
|
|
176
179
|
"stringInterning": true
|
|
177
180
|
},
|
|
178
|
-
"backup": {
|
|
179
|
-
"enabled": false,
|
|
180
|
-
"location": "./i18ntk-backups",
|
|
181
|
-
"singleFileMode": false,
|
|
182
|
-
"singleBackupFile": "i18ntk-central-backup.json",
|
|
183
|
-
"retentionDays": 30,
|
|
184
|
-
"maxBackups": 1
|
|
185
|
-
},
|
|
181
|
+
"backup": {
|
|
182
|
+
"enabled": false,
|
|
183
|
+
"location": "./i18ntk-backups",
|
|
184
|
+
"singleFileMode": false,
|
|
185
|
+
"singleBackupFile": "i18ntk-central-backup.json",
|
|
186
|
+
"retentionDays": 30,
|
|
187
|
+
"maxBackups": 1
|
|
188
|
+
},
|
|
186
189
|
"security": {
|
|
187
190
|
"adminPinEnabled": false,
|
|
188
191
|
"adminPinPromptOnInit": true,
|
|
@@ -250,69 +253,69 @@ const DEFAULT_CONFIG = {
|
|
|
250
253
|
|
|
251
254
|
// Environment variable support has been removed in favor of exclusive .i18ntk-config configuration
|
|
252
255
|
|
|
253
|
-
let currentConfig = null;
|
|
254
|
-
let configLoadInProgress = false;
|
|
255
|
-
let recursionDepth = 0;
|
|
256
|
-
const MAX_RECURSION_DEPTH = 15; // Increased to handle legitimate sequential calls
|
|
257
|
-
let configSaveQueue = Promise.resolve();
|
|
258
|
-
|
|
259
|
-
function sleep(ms) {
|
|
260
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async function acquireConfigLock(timeoutMs = CONFIG_LOCK_TIMEOUT_MS) {
|
|
264
|
-
const start = Date.now();
|
|
265
|
-
let lockHandle = null;
|
|
266
|
-
|
|
267
|
-
while (!lockHandle) {
|
|
268
|
-
try {
|
|
269
|
-
lockHandle = await fs.promises.open(CONFIG_LOCK_PATH, 'wx');
|
|
270
|
-
await lockHandle.writeFile(String(process.pid), 'utf8');
|
|
271
|
-
break;
|
|
272
|
-
} catch (error) {
|
|
273
|
-
if (!error || error.code !== 'EEXIST') {
|
|
274
|
-
throw error;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Recover from stale lock files left by crashed processes.
|
|
278
|
-
try {
|
|
279
|
-
const stats = await fs.promises.stat(CONFIG_LOCK_PATH);
|
|
280
|
-
if (Date.now() - stats.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
281
|
-
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
} catch (_) {
|
|
285
|
-
// Lock may have been released between exists check and stat/unlink.
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (Date.now() - start >= timeoutMs) {
|
|
289
|
-
throw new Error(`Timed out waiting for config lock after ${timeoutMs}ms`);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
await sleep(CONFIG_LOCK_RETRY_MS);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return async function releaseConfigLock() {
|
|
297
|
-
try {
|
|
298
|
-
if (lockHandle) {
|
|
299
|
-
await lockHandle.close();
|
|
300
|
-
}
|
|
301
|
-
} catch (_) {
|
|
302
|
-
// Best-effort close only.
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
306
|
-
} catch (_) {
|
|
307
|
-
// Best-effort cleanup only.
|
|
308
|
-
}
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function isAutosaveDisabled() {
|
|
313
|
-
const flag = String(process.env.I18NTK_DISABLE_AUTOSAVE || '').trim().toLowerCase();
|
|
314
|
-
return flag === '1' || flag === 'true' || flag === 'yes';
|
|
315
|
-
}
|
|
256
|
+
let currentConfig = null;
|
|
257
|
+
let configLoadInProgress = false;
|
|
258
|
+
let recursionDepth = 0;
|
|
259
|
+
const MAX_RECURSION_DEPTH = 15; // Increased to handle legitimate sequential calls
|
|
260
|
+
let configSaveQueue = Promise.resolve();
|
|
261
|
+
|
|
262
|
+
function sleep(ms) {
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function acquireConfigLock(timeoutMs = CONFIG_LOCK_TIMEOUT_MS) {
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
let lockHandle = null;
|
|
269
|
+
|
|
270
|
+
while (!lockHandle) {
|
|
271
|
+
try {
|
|
272
|
+
lockHandle = await fs.promises.open(CONFIG_LOCK_PATH, 'wx');
|
|
273
|
+
await lockHandle.writeFile(String(process.pid), 'utf8');
|
|
274
|
+
break;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!error || error.code !== 'EEXIST') {
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Recover from stale lock files left by crashed processes.
|
|
281
|
+
try {
|
|
282
|
+
const stats = await fs.promises.stat(CONFIG_LOCK_PATH);
|
|
283
|
+
if (Date.now() - stats.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
284
|
+
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
} catch (_) {
|
|
288
|
+
// Lock may have been released between exists check and stat/unlink.
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (Date.now() - start >= timeoutMs) {
|
|
292
|
+
throw new Error(`Timed out waiting for config lock after ${timeoutMs}ms`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await sleep(CONFIG_LOCK_RETRY_MS);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return async function releaseConfigLock() {
|
|
300
|
+
try {
|
|
301
|
+
if (lockHandle) {
|
|
302
|
+
await lockHandle.close();
|
|
303
|
+
}
|
|
304
|
+
} catch (_) {
|
|
305
|
+
// Best-effort close only.
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
309
|
+
} catch (_) {
|
|
310
|
+
// Best-effort cleanup only.
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isAutosaveDisabled() {
|
|
316
|
+
const flag = String(process.env.I18NTK_DISABLE_AUTOSAVE || '').trim().toLowerCase();
|
|
317
|
+
return flag === '1' || flag === 'true' || flag === 'yes';
|
|
318
|
+
}
|
|
316
319
|
|
|
317
320
|
function clone(obj) {
|
|
318
321
|
return JSON.parse(JSON.stringify(obj));
|
|
@@ -382,21 +385,21 @@ function tryReadJson(filePath) {
|
|
|
382
385
|
return null;
|
|
383
386
|
}
|
|
384
387
|
|
|
385
|
-
const parsed = SecurityUtils.safeParseJSON(data);
|
|
386
|
-
if (parsed && typeof parsed === 'object') {
|
|
387
|
-
return parsed;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
console.error(`[i18ntk] Error parsing JSON from ${filePath}: Invalid JSON content`);
|
|
391
|
-
// Create a backup of the corrupted file
|
|
392
|
-
const backupPath = `${filePath}.corrupted-${Date.now()}.bak`;
|
|
393
|
-
try {
|
|
394
|
-
SecurityUtils.safeWriteFileSync(backupPath, data, path.dirname(backupPath), 'utf8');
|
|
395
|
-
console.warn(`[i18ntk] Created backup of corrupted config at ${backupPath}`);
|
|
396
|
-
} catch (backupError) {
|
|
397
|
-
console.error(`[i18ntk] Failed to create backup of corrupted config: ${backupError.message}`);
|
|
398
|
-
}
|
|
399
|
-
return null;
|
|
388
|
+
const parsed = SecurityUtils.safeParseJSON(data);
|
|
389
|
+
if (parsed && typeof parsed === 'object') {
|
|
390
|
+
return parsed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
console.error(`[i18ntk] Error parsing JSON from ${filePath}: Invalid JSON content`);
|
|
394
|
+
// Create a backup of the corrupted file
|
|
395
|
+
const backupPath = `${filePath}.corrupted-${Date.now()}.bak`;
|
|
396
|
+
try {
|
|
397
|
+
SecurityUtils.safeWriteFileSync(backupPath, data, path.dirname(backupPath), 'utf8');
|
|
398
|
+
console.warn(`[i18ntk] Created backup of corrupted config at ${backupPath}`);
|
|
399
|
+
} catch (backupError) {
|
|
400
|
+
console.error(`[i18ntk] Failed to create backup of corrupted config: ${backupError.message}`);
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
400
403
|
} catch (error) {
|
|
401
404
|
console.error(`[i18ntk] Error reading config file at ${filePath}: ${error.message}`);
|
|
402
405
|
return null;
|
|
@@ -417,13 +420,13 @@ async function migrateLegacyIfNeeded(baseCfg) {
|
|
|
417
420
|
// Best-effort removal of legacy file to prevent future use
|
|
418
421
|
try { fs.unlinkSync(LEGACY_CONFIG_PATH); } catch (_) {}
|
|
419
422
|
// Deprecation notice
|
|
420
|
-
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Configuration was migrated to project .i18ntk-config.');
|
|
421
|
-
return merged;
|
|
422
|
-
} catch (_) {
|
|
423
|
-
// If write fails, fall back to in-memory config without deleting legacy
|
|
424
|
-
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Using migrated settings in memory; failed to persist to .i18ntk-config.');
|
|
425
|
-
return merged;
|
|
426
|
-
}
|
|
423
|
+
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Configuration was migrated to project .i18ntk-config.');
|
|
424
|
+
return merged;
|
|
425
|
+
} catch (_) {
|
|
426
|
+
// If write fails, fall back to in-memory config without deleting legacy
|
|
427
|
+
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Using migrated settings in memory; failed to persist to .i18ntk-config.');
|
|
428
|
+
return merged;
|
|
429
|
+
}
|
|
427
430
|
}
|
|
428
431
|
}
|
|
429
432
|
return null;
|
|
@@ -487,73 +490,76 @@ function loadConfig() {
|
|
|
487
490
|
}
|
|
488
491
|
}
|
|
489
492
|
|
|
490
|
-
async function saveConfig(cfg = currentConfig) {
|
|
491
|
-
if (!cfg || typeof cfg !== 'object') return;
|
|
492
|
-
|
|
493
|
-
// Runtime/server safety valve: allow disabling disk writes entirely.
|
|
494
|
-
if (isAutosaveDisabled()) {
|
|
495
|
-
currentConfig = cfg;
|
|
496
|
-
if (!autosaveDisabledWarned) {
|
|
497
|
-
autosaveDisabledWarned = true;
|
|
498
|
-
console.warn('[i18ntk] Autosave disabled by I18NTK_DISABLE_AUTOSAVE. Keeping configuration in memory only.');
|
|
499
|
-
}
|
|
500
|
-
return false;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
configSaveQueue = configSaveQueue.then(async () => {
|
|
504
|
-
let tempPath = null;
|
|
505
|
-
let releaseLock = null;
|
|
506
|
-
try {
|
|
507
|
-
// Ensure settings directory exists before any lock/file operations.
|
|
508
|
-
await fs.promises.mkdir(PROJECT_SETTINGS_DIR, { recursive: true });
|
|
509
|
-
|
|
510
|
-
releaseLock = await acquireConfigLock();
|
|
511
|
-
|
|
512
|
-
const serialized = JSON.stringify(cfg, null, 2);
|
|
513
|
-
if (typeof serialized !== 'string' || serialized.length === 0) {
|
|
514
|
-
throw new Error('Cannot save empty configuration payload');
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Use a unique temp file to avoid concurrent writer races.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
return
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
493
|
+
async function saveConfig(cfg = currentConfig) {
|
|
494
|
+
if (!cfg || typeof cfg !== 'object') return;
|
|
495
|
+
|
|
496
|
+
// Runtime/server safety valve: allow disabling disk writes entirely.
|
|
497
|
+
if (isAutosaveDisabled()) {
|
|
498
|
+
currentConfig = cfg;
|
|
499
|
+
if (!autosaveDisabledWarned) {
|
|
500
|
+
autosaveDisabledWarned = true;
|
|
501
|
+
console.warn('[i18ntk] Autosave disabled by I18NTK_DISABLE_AUTOSAVE. Keeping configuration in memory only.');
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
configSaveQueue = configSaveQueue.then(async () => {
|
|
507
|
+
let tempPath = null;
|
|
508
|
+
let releaseLock = null;
|
|
509
|
+
try {
|
|
510
|
+
// Ensure settings directory exists before any lock/file operations.
|
|
511
|
+
await fs.promises.mkdir(PROJECT_SETTINGS_DIR, { recursive: true });
|
|
512
|
+
|
|
513
|
+
releaseLock = await acquireConfigLock();
|
|
514
|
+
|
|
515
|
+
const serialized = JSON.stringify(cfg, null, 2);
|
|
516
|
+
if (typeof serialized !== 'string' || serialized.length === 0) {
|
|
517
|
+
throw new Error('Cannot save empty configuration payload');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Use a unique temp file to avoid concurrent writer races.
|
|
521
|
+
// Create temp files in the same directory as the config file to ensure they're safe
|
|
522
|
+
// Use a simpler naming pattern to avoid triggering security warnings
|
|
523
|
+
const nonce = `${process.pid}.${Date.now()}`;
|
|
524
|
+
const tempFileName = `.i18ntk-config.temp-${nonce}`;
|
|
525
|
+
tempPath = path.join(PROJECT_SETTINGS_DIR, tempFileName);
|
|
526
|
+
await fs.promises.writeFile(tempPath, serialized, 'utf8');
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await fs.promises.rename(tempPath, PROJECT_CONFIG_PATH);
|
|
530
|
+
} catch (renameError) {
|
|
531
|
+
// If destination dir disappeared between checks, recreate and retry once.
|
|
532
|
+
if (renameError && renameError.code === 'ENOENT') {
|
|
533
|
+
await fs.promises.mkdir(PROJECT_SETTINGS_DIR, { recursive: true });
|
|
534
|
+
await fs.promises.rename(tempPath, PROJECT_CONFIG_PATH);
|
|
535
|
+
} else {
|
|
536
|
+
throw renameError;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
currentConfig = cfg;
|
|
541
|
+
return true;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
console.error('[i18ntk] Error saving configuration:', error.message);
|
|
544
|
+
return false;
|
|
545
|
+
} finally {
|
|
546
|
+
if (releaseLock) {
|
|
547
|
+
await releaseLock();
|
|
548
|
+
}
|
|
549
|
+
if (tempPath) {
|
|
550
|
+
try {
|
|
551
|
+
if (SecurityUtils.safeExistsSync(tempPath, path.dirname(tempPath))) {
|
|
552
|
+
fs.unlinkSync(tempPath);
|
|
553
|
+
}
|
|
554
|
+
} catch (_) {
|
|
555
|
+
// Best-effort temp cleanup only.
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
return configSaveQueue;
|
|
562
|
+
}
|
|
557
563
|
|
|
558
564
|
function getConfig() {
|
|
559
565
|
// Check for recursion
|
|
@@ -575,28 +581,28 @@ function getConfig() {
|
|
|
575
581
|
// No need to check here - handled by getUnifiedConfig
|
|
576
582
|
|
|
577
583
|
// Check if config file exists
|
|
578
|
-
if (SecurityUtils.safeExistsSync(PROJECT_CONFIG_PATH)) {
|
|
579
|
-
const rawConfig = SecurityUtils.safeReadFileSync(PROJECT_CONFIG_PATH, path.dirname(PROJECT_CONFIG_PATH), 'utf8');
|
|
580
|
-
const config = SecurityUtils.safeParseJSON(rawConfig);
|
|
581
|
-
if (config && typeof config === 'object') {
|
|
582
|
-
currentConfig = config;
|
|
583
|
-
return resolvePaths(config);
|
|
584
|
-
}
|
|
585
|
-
throw new Error('Invalid project configuration JSON');
|
|
586
|
-
}
|
|
584
|
+
if (SecurityUtils.safeExistsSync(PROJECT_CONFIG_PATH)) {
|
|
585
|
+
const rawConfig = SecurityUtils.safeReadFileSync(PROJECT_CONFIG_PATH, path.dirname(PROJECT_CONFIG_PATH), 'utf8');
|
|
586
|
+
const config = SecurityUtils.safeParseJSON(rawConfig);
|
|
587
|
+
if (config && typeof config === 'object') {
|
|
588
|
+
currentConfig = config;
|
|
589
|
+
return resolvePaths(config);
|
|
590
|
+
}
|
|
591
|
+
throw new Error('Invalid project configuration JSON');
|
|
592
|
+
}
|
|
587
593
|
|
|
588
594
|
// Check for legacy config for migration
|
|
589
595
|
if (SecurityUtils.safeExistsSync(LEGACY_CONFIG_PATH)) {
|
|
590
596
|
console.log('📦 Migrating legacy configuration...');
|
|
591
|
-
const legacyRaw = SecurityUtils.safeReadFileSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH), 'utf8');
|
|
592
|
-
const legacyConfig = SecurityUtils.safeParseJSON(legacyRaw);
|
|
593
|
-
if (!legacyConfig || typeof legacyConfig !== 'object') {
|
|
594
|
-
throw new Error('Invalid legacy configuration JSON');
|
|
595
|
-
}
|
|
597
|
+
const legacyRaw = SecurityUtils.safeReadFileSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH), 'utf8');
|
|
598
|
+
const legacyConfig = SecurityUtils.safeParseJSON(legacyRaw);
|
|
599
|
+
if (!legacyConfig || typeof legacyConfig !== 'object') {
|
|
600
|
+
throw new Error('Invalid legacy configuration JSON');
|
|
601
|
+
}
|
|
596
602
|
const migratedConfig = { ...DEFAULT_CONFIG, ...legacyConfig };
|
|
597
|
-
saveConfig(migratedConfig).catch((err) => {
|
|
598
|
-
console.warn('[i18ntk] Warning: failed to persist migrated configuration:', err.message);
|
|
599
|
-
});
|
|
603
|
+
saveConfig(migratedConfig).catch((err) => {
|
|
604
|
+
console.warn('[i18ntk] Warning: failed to persist migrated configuration:', err.message);
|
|
605
|
+
});
|
|
600
606
|
currentConfig = migratedConfig;
|
|
601
607
|
|
|
602
608
|
// Clean up legacy config
|
|
@@ -614,9 +620,9 @@ function getConfig() {
|
|
|
614
620
|
|
|
615
621
|
// Use package defaults for new installation
|
|
616
622
|
console.log('📦 Initializing with default configuration...');
|
|
617
|
-
saveConfig(DEFAULT_CONFIG).catch((err) => {
|
|
618
|
-
console.warn('[i18ntk] Warning: failed to persist default configuration:', err.message);
|
|
619
|
-
});
|
|
623
|
+
saveConfig(DEFAULT_CONFIG).catch((err) => {
|
|
624
|
+
console.warn('[i18ntk] Warning: failed to persist default configuration:', err.message);
|
|
625
|
+
});
|
|
620
626
|
currentConfig = DEFAULT_CONFIG;
|
|
621
627
|
return resolvePaths(DEFAULT_CONFIG);
|
|
622
628
|
|
|
@@ -686,4 +692,4 @@ module.exports = {
|
|
|
686
692
|
resolvePaths,
|
|
687
693
|
toRelative,
|
|
688
694
|
normalizePathValue,
|
|
689
|
-
}
|
|
695
|
+
}
|
package/utils/security.js
CHANGED
|
@@ -27,7 +27,7 @@ function getI18n() {
|
|
|
27
27
|
i18n = require('./i18n-helper');
|
|
28
28
|
} catch (error) {
|
|
29
29
|
// Fallback to simple identity function if i18n fails to load
|
|
30
|
-
|
|
30
|
+
SecurityUtils.logSecurityEvent('i18n-helper not available, using fallback messages', 'warn');
|
|
31
31
|
return { t: (key, params = {}) => key };
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -41,9 +41,21 @@ function getI18n() {
|
|
|
41
41
|
*/
|
|
42
42
|
class SecurityUtils {
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Whitelist patterns for our own package artifacts
|
|
45
|
+
static PACKAGE_ARTIFACT_WHITELIST = [
|
|
46
|
+
/\.i18ntk-config\.temp-\d+\.\d+$/, // .i18ntk-config.temp-1234.5678
|
|
47
|
+
/\.i18ntk-config\.\d+\.\d+\.tmp$/, // Legacy pattern: .i18ntk-config.1234.5678.tmp
|
|
48
|
+
/config\.temp-\d+\.\d+$/, // config.temp-1234.5678
|
|
49
|
+
/config\.\d+\.\d+\.tmp$/, // Legacy pattern: config.1234.5678.tmp
|
|
50
|
+
/\.temp-config\.json$/, // .temp-config.json
|
|
51
|
+
/\.last-config\.json$/, // .last-config.json
|
|
52
|
+
/\.lock$/, // .lock files
|
|
53
|
+
/settings\.lock$/ // settings.lock
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Static properties for operation tracking
|
|
57
|
+
static _operationStack = new Set();
|
|
58
|
+
static _logging = false;
|
|
47
59
|
|
|
48
60
|
constructor() {
|
|
49
61
|
// Instance constructor - static properties are already initialized
|
|
@@ -93,9 +105,12 @@ class SecurityUtils {
|
|
|
93
105
|
|
|
94
106
|
return result;
|
|
95
107
|
} catch (error) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
const i18n = getI18n();
|
|
109
|
+
SecurityUtils.logSecurityEvent('Operation error', 'error', {
|
|
110
|
+
operation: operationName,
|
|
111
|
+
error: error.message
|
|
112
|
+
});
|
|
113
|
+
return null;
|
|
99
114
|
} finally {
|
|
100
115
|
SecurityUtils._operationStack.delete(operationName);
|
|
101
116
|
}
|
|
@@ -108,17 +123,20 @@ class SecurityUtils {
|
|
|
108
123
|
* @param {object} details - Additional details
|
|
109
124
|
*/
|
|
110
125
|
static logSecurityEvent(event, level = 'info', details = {}) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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');
|
|
122
140
|
|
|
123
141
|
const levels = { error: 0, warn: 1, warning: 1, info: 2 };
|
|
124
142
|
const messageLevel = levels[level.toLowerCase()] ?? 2;
|
|
@@ -158,7 +176,12 @@ class SecurityUtils {
|
|
|
158
176
|
const useI18n = i18n && i18n.isInitialized && typeof i18n.t === 'function';
|
|
159
177
|
|
|
160
178
|
try {
|
|
161
|
-
|
|
179
|
+
// Check against whitelist patterns for our own package artifacts
|
|
180
|
+
if (SecurityUtils.PACKAGE_ARTIFACT_WHITELIST.some(pattern => pattern.test(filePath))) {
|
|
181
|
+
return filePath;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
162
185
|
const message = useI18n
|
|
163
186
|
? i18n.t('security.pathValidationFailed')
|
|
164
187
|
: 'Path validation failed';
|
|
@@ -263,14 +286,14 @@ class SecurityUtils {
|
|
|
263
286
|
// Read file with size limit (10MB max)
|
|
264
287
|
const stats = fs.statSync(validatedPath);
|
|
265
288
|
if (stats.size > 10 * 1024 * 1024) {
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
SecurityUtils.logSecurityEvent('File too large', 'warn', { filePath: validatedPath });
|
|
290
|
+
return null;
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
return fs.readFileSync(validatedPath, encoding);
|
|
271
294
|
} catch (error) {
|
|
272
|
-
|
|
273
|
-
|
|
295
|
+
SecurityUtils.logSecurityEvent('File read error', 'error', { errorMessage: error.message });
|
|
296
|
+
return null;
|
|
274
297
|
}
|
|
275
298
|
}
|
|
276
299
|
|
|
@@ -284,7 +307,7 @@ class SecurityUtils {
|
|
|
284
307
|
// Validate content is a string or Buffer
|
|
285
308
|
if (typeof content !== 'string' && !Buffer.isBuffer(content)) {
|
|
286
309
|
const i18n = getI18n();
|
|
287
|
-
|
|
310
|
+
SecurityUtils.logSecurityEvent('File write error: Content must be a string or Buffer', 'error');
|
|
288
311
|
return false;
|
|
289
312
|
}
|
|
290
313
|
|
|
@@ -292,7 +315,7 @@ class SecurityUtils {
|
|
|
292
315
|
const contentSize = typeof content === 'string' ? content.length : content.length;
|
|
293
316
|
if (contentSize > 10 * 1024 * 1024) {
|
|
294
317
|
const i18n = getI18n();
|
|
295
|
-
|
|
318
|
+
SecurityUtils.logSecurityEvent('Content too large for file', 'warn', { filePath: validatedPath });
|
|
296
319
|
return false;
|
|
297
320
|
}
|
|
298
321
|
|
|
@@ -305,7 +328,7 @@ class SecurityUtils {
|
|
|
305
328
|
return true;
|
|
306
329
|
} catch (error) {
|
|
307
330
|
const i18n = getI18n();
|
|
308
|
-
|
|
331
|
+
SecurityUtils.logSecurityEvent('File write error', 'error', { errorMessage: error.message });
|
|
309
332
|
return false;
|
|
310
333
|
}
|
|
311
334
|
}
|
|
@@ -399,8 +422,8 @@ class SecurityUtils {
|
|
|
399
422
|
const normalized = trimmed.charCodeAt(0) === 0xFEFF ? trimmed.slice(1) : trimmed;
|
|
400
423
|
return JSON.parse(normalized);
|
|
401
424
|
} catch (error) {
|
|
402
|
-
|
|
403
|
-
|
|
425
|
+
SecurityUtils.logSecurityEvent('Invalid JSON content', 'error', { error: error.message });
|
|
426
|
+
return fallback;
|
|
404
427
|
}
|
|
405
428
|
}
|
|
406
429
|
|
|
@@ -443,7 +466,7 @@ class SecurityUtils {
|
|
|
443
466
|
const isCommonContent = sanitized.length < 1000 && !sanitized.includes('<script');
|
|
444
467
|
if (!isFilePath && !isCommonContent) {
|
|
445
468
|
const i18n = getI18n();
|
|
446
|
-
|
|
469
|
+
SecurityUtils.logSecurityEvent('Input contains disallowed characters', 'warn');
|
|
447
470
|
}
|
|
448
471
|
// Allow more characters for file paths and content
|
|
449
472
|
sanitized = sanitized.replace(/[^a-zA-Z0-9\s\-_\.\,\!\?\(\)\[\]\{\}\:\;"'\/\\]/g, '');
|
|
@@ -456,10 +479,44 @@ class SecurityUtils {
|
|
|
456
479
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
457
480
|
}
|
|
458
481
|
|
|
482
|
+
static safeJoin(basePath, ...paths) {
|
|
483
|
+
if (!basePath || typeof basePath !== 'string') {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const resolvedBase = path.resolve(basePath);
|
|
488
|
+
const joinedPath = path.join(resolvedBase, ...paths);
|
|
489
|
+
const resolvedPath = path.resolve(joinedPath);
|
|
490
|
+
|
|
491
|
+
// 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
|
+
}
|
|
500
|
+
return resolvedPath;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
SecurityUtils.logSecurityEvent('Error in safeJoin', 'error', {
|
|
503
|
+
basePath,
|
|
504
|
+
paths,
|
|
505
|
+
error: error.message
|
|
506
|
+
});
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
459
511
|
static isSafePath(filePath) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
512
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Check against whitelist patterns for our own package artifacts
|
|
517
|
+
if (SecurityUtils.PACKAGE_ARTIFACT_WHITELIST.some(pattern => pattern.test(filePath))) {
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
463
520
|
|
|
464
521
|
// Allow legitimate Windows drive letter paths
|
|
465
522
|
if (filePath.match(/^[A-Z]:[\/\\]/)) {
|
|
@@ -531,12 +588,12 @@ class SecurityUtils {
|
|
|
531
588
|
'theme', 'ui', 'setup', 'reports', 'display', 'interface',
|
|
532
589
|
// Security and settings
|
|
533
590
|
'security', 'settings', 'preferences', 'config', 'configuration',
|
|
534
|
-
// Additional common properties
|
|
535
|
-
'autoSave', 'autoBackup', 'validateOnSave', 'showWarnings', 'verbose',
|
|
536
|
-
'timeout', 'retries', 'batchSize', 'maxConcurrency', 'cacheEnabled',
|
|
537
|
-
// Date and reporting options used by existing settings
|
|
538
|
-
'dateFormat', 'timeFormat', 'timezone', 'reportLanguage', 'dateTime'
|
|
539
|
-
]);
|
|
591
|
+
// Additional common properties
|
|
592
|
+
'autoSave', 'autoBackup', 'validateOnSave', 'showWarnings', 'verbose',
|
|
593
|
+
'timeout', 'retries', 'batchSize', 'maxConcurrency', 'cacheEnabled',
|
|
594
|
+
// Date and reporting options used by existing settings
|
|
595
|
+
'dateFormat', 'timeFormat', 'timezone', 'reportLanguage', 'dateTime'
|
|
596
|
+
]);
|
|
540
597
|
|
|
541
598
|
// Remove unknown properties
|
|
542
599
|
Object.keys(sanitized).forEach(key => {
|