helm-env-delta 1.14.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,9 @@ HelmEnvDelta (`hed`) automates environment synchronization for GitOps workflows
64
64
 
65
65
  🛡️ **Safety First** - Pre-execution summary, first-run tips, improved error messages with helpful examples.
66
66
 
67
- ⚡ **High Performance** - 45-60% faster than alternatives with intelligent caching and parallel processing.
67
+ ⚡ **High Performance** - Intelligent caching and parallel processing. Formatting rules, compiled regexes, and array normalization are all cached for fast repeated runs.
68
+
69
+ 🔐 **Security Hardened** - Regex inputs (stop rules, transforms, pattern files) are validated against ReDoS (catastrophic backtracking). Fixed values are validated against prototype pollution attacks.
68
70
 
69
71
  🔔 **Auto Updates** - Notifies when newer versions are available (skips in CI/CD).
70
72
 
@@ -523,7 +525,7 @@ fixedValues:
523
525
 
524
526
  **Supported filter operators:** `=` (equals), `^=` (startsWith), `$=` (endsWith), `*=` (contains) - updates ALL matching items
525
527
 
526
- **Value types:** String, number, boolean, null, object, array
528
+ **Value types:** String, number, boolean, null, object, array (objects with `__proto__`, `constructor`, or `prototype` keys are rejected)
527
529
 
528
530
  **Behavior:**
529
531
 
@@ -678,6 +680,8 @@ stopRules:
678
680
 
679
681
  **Override:** Use `--force` to bypass stop rules when needed.
680
682
 
683
+ **Regex safety:** All `regex` patterns (inline and from files) are validated against catastrophic backtracking (ReDoS). Patterns with nested quantifiers on groups (e.g., `(a+)+`) are rejected at config load time.
684
+
681
685
  **Visibility:** Stop rule violations appear in console output, JSON reports, and HTML reports (dry-run mode only, as a collapsible table in the header area).
682
686
 
683
687
  ---
@@ -82,7 +82,7 @@ declare const keySortRuleSchema: z.ZodObject<{
82
82
  }, z.core.$strip>;
83
83
  declare const fixedValueRuleSchema: z.ZodObject<{
84
84
  path: z.ZodString;
85
- value: z.ZodUnknown;
85
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
86
86
  }, z.core.$strip>;
87
87
  declare const transformRuleSchema: z.ZodObject<{
88
88
  find: z.ZodString;
@@ -173,7 +173,7 @@ declare const baseConfigSchema: z.ZodObject<{
173
173
  }, z.core.$strict>], "type">>>>;
174
174
  fixedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
175
175
  path: z.ZodString;
176
- value: z.ZodUnknown;
176
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
177
177
  }, z.core.$strip>>>>;
178
178
  }, z.core.$strip>;
179
179
  declare const finalConfigSchema: z.ZodObject<{
@@ -227,7 +227,7 @@ declare const finalConfigSchema: z.ZodObject<{
227
227
  }, z.core.$strict>], "type">>>>;
228
228
  fixedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
229
229
  path: z.ZodString;
230
- value: z.ZodUnknown;
230
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
231
231
  }, z.core.$strip>>>>;
232
232
  include: z.ZodDefault<z.ZodArray<z.ZodString>>;
233
233
  exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -302,7 +302,7 @@ declare const formatOnlyConfigSchema: z.ZodObject<{
302
302
  }, z.core.$strict>], "type">>>>;
303
303
  fixedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
304
304
  path: z.ZodString;
305
- value: z.ZodUnknown;
305
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
306
306
  }, z.core.$strip>>>>;
307
307
  include: z.ZodDefault<z.ZodArray<z.ZodString>>;
308
308
  exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.isConfigValidationError = exports.ConfigValidationError = exports.parseConfig = exports.parseFormatOnlyConfig = exports.parseFinalConfig = exports.parseBaseConfig = void 0;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const zod_1 = require("zod");
9
+ const regexSafety_1 = require("../utils/regexSafety");
9
10
  const ZodError_1 = require("./ZodError");
10
11
  const semverMajorUpgradeRuleSchema = zod_1.z.object({
11
12
  type: zod_1.z.literal('semverMajorUpgrade'),
@@ -47,6 +48,10 @@ const regexRuleSchema = zod_1.z
47
48
  }, {
48
49
  message: 'Invalid regular expression pattern',
49
50
  path: ['regex']
51
+ })
52
+ .refine((data) => (0, regexSafety_1.isSafeRegex)(data.regex), {
53
+ message: 'Potentially unsafe regex pattern (may cause catastrophic backtracking / ReDoS)',
54
+ path: ['regex']
50
55
  });
51
56
  const regexFileRuleSchema = zod_1.z.object({
52
57
  type: zod_1.z.literal('regexFile'),
@@ -80,9 +85,25 @@ const arraySortRuleSchema = zod_1.z.object({
80
85
  order: zod_1.z.enum(['asc', 'desc']).default('asc')
81
86
  });
82
87
  const keySortRuleSchema = zod_1.z.object({ path: zod_1.z.string().min(1) });
88
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
89
+ const hasDangerousKeys = (value) => {
90
+ if (value === null || typeof value !== 'object')
91
+ return false;
92
+ if (Array.isArray(value))
93
+ return value.some((item) => hasDangerousKeys(item));
94
+ for (const key of Object.keys(value))
95
+ if (DANGEROUS_KEYS.has(key) || hasDangerousKeys(value[key]))
96
+ return true;
97
+ return false;
98
+ };
99
+ const safeFixedValue = zod_1.z
100
+ .union([zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean(), zod_1.z.null(), zod_1.z.array(zod_1.z.unknown()), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())])
101
+ .refine((value) => !hasDangerousKeys(value), {
102
+ message: 'Value must not contain prototype-polluting keys (__proto__, constructor, prototype)'
103
+ });
83
104
  const fixedValueRuleSchema = zod_1.z.object({
84
105
  path: zod_1.z.string().min(1).describe('JSONPath to the value to set'),
85
- value: zod_1.z.unknown().describe('The constant value to set (any type: string, number, boolean, null, object, array)')
106
+ value: safeFixedValue.describe('The constant value to set (any type: string, number, boolean, null, object, array)')
86
107
  });
87
108
  const transformRuleSchema = zod_1.z
88
109
  .object({
@@ -100,6 +121,10 @@ const transformRuleSchema = zod_1.z
100
121
  }, {
101
122
  message: 'Invalid regular expression pattern',
102
123
  path: ['find']
124
+ })
125
+ .refine((data) => (0, regexSafety_1.isSafeRegex)(data.find), {
126
+ message: 'Potentially unsafe regex pattern (may cause catastrophic backtracking / ReDoS)',
127
+ path: ['find']
103
128
  });
104
129
  const transformRulesSchema = zod_1.z
105
130
  .object({
@@ -1,4 +1,4 @@
1
- import { Config, FixedValueConfig, TransformConfig } from '../config';
1
+ import { Config, FixedValueConfig, FixedValueRule, TransformConfig } from '../config';
2
2
  import { FileMap } from './fileLoader';
3
3
  export interface FileDiffResult {
4
4
  addedFiles: AddedFile[];
@@ -16,6 +16,7 @@ export interface ChangedFile {
16
16
  rawParsedSource: unknown;
17
17
  rawParsedDest: unknown;
18
18
  skipPaths: string[];
19
+ fixedValueRules: FixedValueRule[];
19
20
  normalizedSource?: unknown;
20
21
  normalizedDest?: unknown;
21
22
  parsedSource?: unknown;
@@ -117,10 +117,33 @@ const applySkipPaths = (data, skipPaths) => {
117
117
  return data;
118
118
  if (skipPaths.length === 0)
119
119
  return data;
120
- const cloned = structuredClone(data);
120
+ const affectedKeys = new Set();
121
+ let needsFullClone = false;
122
+ for (const skipPath of skipPaths) {
123
+ const parts = (0, jsonPath_1.parseJsonPath)(skipPath);
124
+ if (parts.length === 0)
125
+ continue;
126
+ const firstPart = parts[0];
127
+ if (firstPart === '*' || ((0, jsonPath_1.isFilterSegment)(firstPart) && Array.isArray(data))) {
128
+ needsFullClone = true;
129
+ break;
130
+ }
131
+ affectedKeys.add(firstPart);
132
+ }
133
+ if (needsFullClone) {
134
+ const cloned = structuredClone(data);
135
+ for (const path of skipPaths)
136
+ deleteJsonPath(cloned, path);
137
+ return cloned;
138
+ }
139
+ const object = data;
140
+ const result = { ...object };
141
+ for (const key of affectedKeys)
142
+ if (key in result)
143
+ result[key] = structuredClone(result[key]);
121
144
  for (const path of skipPaths)
122
- deleteJsonPath(cloned, path);
123
- return cloned;
145
+ deleteJsonPath(result, path);
146
+ return result;
124
147
  };
125
148
  const getSkipPathsForFile = (filePath, skipPath) => {
126
149
  if (!skipPath)
@@ -193,6 +216,7 @@ const processYamlFile = (options) => {
193
216
  rawParsedSource: sourceFiltered,
194
217
  rawParsedDest: destinationFiltered,
195
218
  skipPaths: pathsToSkip,
219
+ fixedValueRules,
196
220
  normalizedSource,
197
221
  normalizedDest: normalizedDestination,
198
222
  parsedSource: sourceParsed,
@@ -237,7 +261,8 @@ const processChangedFiles = (sourceFiles, destinationFiles, skipPath, transforms
237
261
  processedDestContent: destinationContent,
238
262
  rawParsedSource: sourceContent,
239
263
  rawParsedDest: destinationContent,
240
- skipPaths: []
264
+ skipPaths: [],
265
+ fixedValueRules: []
241
266
  });
242
267
  }
243
268
  return { changedFiles, unchangedFiles };
@@ -93,9 +93,21 @@ const deepMerge = (fullTarget, filteredSource, filteredTarget, currentPath = [],
93
93
  }
94
94
  result.push(sourceItem);
95
95
  }
96
- for (const item of fullTargetArray)
97
- if ((0, arrayMerger_1.shouldPreserveItem)(item, applicableFilters, result))
96
+ const resultKeySet = new Set(result
97
+ .filter((item) => !!item && typeof item === 'object')
98
+ .map((item) => JSON.stringify(applicableFilters.map((f) => item[f.filter.property]))));
99
+ for (const item of fullTargetArray) {
100
+ if (!item || typeof item !== 'object')
101
+ continue;
102
+ const { matches } = (0, arrayMerger_1.itemMatchesAnyFilter)(item, applicableFilters);
103
+ if (!matches)
104
+ continue;
105
+ const key = JSON.stringify(applicableFilters.map((f) => item[f.filter.property]));
106
+ if (!resultKeySet.has(key)) {
98
107
  result.push(item);
108
+ resultKeySet.add(key);
109
+ }
110
+ }
99
111
  return result;
100
112
  }
101
113
  if (typeof filteredSource === 'object' && typeof fullTarget === 'object') {
@@ -142,7 +154,7 @@ const mergeYamlContent = (destinationContent, processedSourceContent, filteredDe
142
154
  });
143
155
  }
144
156
  try {
145
- return yaml_1.default.stringify(merged);
157
+ return { content: yaml_1.default.stringify(merged), merged };
146
158
  }
147
159
  catch (error) {
148
160
  throw new FileUpdaterError('Failed to serialize merged YAML', {
@@ -198,15 +210,21 @@ const updateFile = async (options) => {
198
210
  logger.fileOp('update', changedFile.path, true);
199
211
  return;
200
212
  }
201
- let contentToWrite = (0, fileType_1.isYamlFile)(changedFile.path)
202
- ? mergeYamlContent(changedFile.destinationContent, changedFile.rawParsedSource, changedFile.rawParsedDest, changedFile.path, changedFile.skipPaths)
203
- : changedFile.sourceContent;
213
+ let contentToWrite;
214
+ let mergedObject;
215
+ if ((0, fileType_1.isYamlFile)(changedFile.path)) {
216
+ const mergeResult = mergeYamlContent(changedFile.destinationContent, changedFile.rawParsedSource, changedFile.rawParsedDest, changedFile.path, changedFile.skipPaths);
217
+ contentToWrite = mergeResult.content;
218
+ mergedObject = mergeResult.merged;
219
+ }
220
+ else
221
+ contentToWrite = changedFile.sourceContent;
204
222
  if ((0, fileType_1.isYamlFile)(changedFile.path)) {
205
- const fixedValueRules = (0, fixedValues_1.getFixedValuesForFile)(changedFile.path, config.fixedValues);
223
+ const fixedValueRules = changedFile.fixedValueRules ?? (0, fixedValues_1.getFixedValuesForFile)(changedFile.path, config.fixedValues);
206
224
  if (fixedValueRules.length > 0) {
207
- const parsed = yaml_1.default.parse(contentToWrite);
208
- (0, fixedValues_1.applyFixedValues)(parsed, fixedValueRules);
209
- contentToWrite = yaml_1.default.stringify(parsed);
225
+ const target = mergedObject ?? yaml_1.default.parse(contentToWrite);
226
+ (0, fixedValues_1.applyFixedValues)(target, fixedValueRules);
227
+ contentToWrite = yaml_1.default.stringify(target);
210
228
  }
211
229
  const effectiveOutputFormat = skipFormat ? undefined : config.outputFormat;
212
230
  contentToWrite = (0, yamlFormatter_1.formatYaml)(contentToWrite, changedFile.path, effectiveOutputFormat);
@@ -18,7 +18,16 @@ exports.YamlFormatterError = (0, errors_1.createErrorClass)('YAML Formatter Erro
18
18
  PATTERN_MATCH_ERROR: 'File pattern matching failed'
19
19
  });
20
20
  exports.isYamlFormatterError = (0, errors_1.createErrorTypeGuard)(exports.YamlFormatterError);
21
+ const formattingRulesCache = new WeakMap();
21
22
  const getFormattingRules = (filePath, outputFormat) => {
23
+ let fileMap = formattingRulesCache.get(outputFormat);
24
+ if (!fileMap) {
25
+ fileMap = new Map();
26
+ formattingRulesCache.set(outputFormat, fileMap);
27
+ }
28
+ const cached = fileMap.get(filePath);
29
+ if (cached)
30
+ return cached;
22
31
  const keyOrders = [];
23
32
  const keySort = [];
24
33
  const arraySort = [];
@@ -52,7 +61,9 @@ const getFormattingRules = (filePath, outputFormat) => {
52
61
  if (quoteValue)
53
62
  quoteValues.push(quoteValue);
54
63
  }
55
- return { keyOrders, keySort, arraySort, quoteValues };
64
+ const rules = { keyOrders, keySort, arraySort, quoteValues };
65
+ fileMap.set(filePath, rules);
66
+ return rules;
56
67
  };
57
68
  const preserveMultilineStrings = (yamlDocument) => {
58
69
  if (!yamlDocument.contents)
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadRegexPatternsFromKeys = exports.loadRegexPatternArray = exports.isRegexPatternFileLoaderError = exports.RegexPatternFileLoaderError = void 0;
4
4
  const errors_1 = require("./errors");
5
+ const regexSafety_1 = require("./regexSafety");
5
6
  const yamlFileLoader_1 = require("./yamlFileLoader");
6
7
  exports.RegexPatternFileLoaderError = (0, errors_1.createErrorClass)('RegexPatternFileLoaderError', {
7
8
  INVALID_FORMAT_NOT_ARRAY: 'Pattern file must contain a YAML array of regex patterns',
@@ -38,6 +39,8 @@ const validateRegexPattern = (pattern, source) => {
38
39
  cause: error
39
40
  });
40
41
  }
42
+ if (!(0, regexSafety_1.isSafeRegex)(pattern))
43
+ throw new exports.RegexPatternFileLoaderError(`Potentially unsafe regex pattern "${pattern}" ${source} — may cause catastrophic backtracking (ReDoS)`, { code: 'INVALID_REGEX' });
41
44
  };
42
45
  const loadRegexPatternArray = (filePath, configDirectory) => {
43
46
  let parsedData;
@@ -0,0 +1 @@
1
+ export declare const isSafeRegex: (pattern: string) => boolean;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSafeRegex = void 0;
4
+ const isSafeRegex = (pattern) => {
5
+ if (/\([^()]*[*+][^()]*\)[*+{]/.test(pattern))
6
+ return false;
7
+ return true;
8
+ };
9
+ exports.isSafeRegex = isSafeRegex;
@@ -1,11 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyRegexRulesSequentially = void 0;
4
+ const regexCache = new Map();
4
5
  const applyRegexRulesSequentially = (value, rules, throwOnError = false) => {
5
6
  let result = value;
6
7
  for (const rule of rules)
7
8
  try {
8
- const regex = new RegExp(rule.find, 'g');
9
+ let regex = regexCache.get(rule.find);
10
+ if (!regex) {
11
+ regex = new RegExp(rule.find, 'g');
12
+ regexCache.set(rule.find, regex);
13
+ }
9
14
  result = result.replace(regex, rule.replace);
10
15
  }
11
16
  catch (error) {
@@ -15,6 +15,19 @@ const serializeForDiff = (content, isYaml) => {
15
15
  });
16
16
  };
17
17
  exports.serializeForDiff = serializeForDiff;
18
+ const deepSortKeys = (value) => {
19
+ if (value === null || value === undefined)
20
+ return value;
21
+ if (Array.isArray(value))
22
+ return value.map((item) => deepSortKeys(item));
23
+ if (typeof value === 'object') {
24
+ const sorted = {};
25
+ for (const key of Object.keys(value).toSorted())
26
+ sorted[key] = deepSortKeys(value[key]);
27
+ return sorted;
28
+ }
29
+ return value;
30
+ };
18
31
  const normalizeForComparison = (value) => {
19
32
  if (value === null || value === undefined)
20
33
  return value;
@@ -25,7 +38,7 @@ const normalizeForComparison = (value) => {
25
38
  const normalized = value.map((item) => (0, exports.normalizeForComparison)(item));
26
39
  const serializedItems = normalized.map((item) => ({
27
40
  item,
28
- serialized: yaml_1.default.stringify(item, { sortMapEntries: true }) ?? ''
41
+ serialized: JSON.stringify(deepSortKeys(item)) ?? ''
29
42
  }));
30
43
  return serializedItems.toSorted((a, b) => a.serialized.localeCompare(b.serialized)).map(({ item }) => item);
31
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helm-env-delta",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "HelmEnvDelta – environment-aware YAML delta and sync for GitOps",
5
5
  "author": "BCsabaEngine",
6
6
  "license": "ISC",
@@ -65,18 +65,18 @@
65
65
  ],
66
66
  "devDependencies": {
67
67
  "@eslint/js": "^10.0.1",
68
- "@types/node": "^25.3.0",
68
+ "@types/node": "^25.5.0",
69
69
  "@types/picomatch": "^4.0.2",
70
- "@typescript-eslint/eslint-plugin": "^8.56.1",
71
- "@vitest/coverage-v8": "^4.0.18",
72
- "eslint": "^10.0.2",
70
+ "@typescript-eslint/eslint-plugin": "^8.57.0",
71
+ "@vitest/coverage-v8": "^4.1.0",
72
+ "eslint": "^10.0.3",
73
73
  "eslint-config-prettier": "^10.1.8",
74
74
  "eslint-plugin-simple-import-sort": "^12.1.1",
75
75
  "eslint-plugin-unicorn": "^63.0.0",
76
76
  "prettier": "^3.8.1",
77
77
  "tsx": "^4.21.0",
78
78
  "typescript": "^5.9.3",
79
- "vitest": "^4.0.18"
79
+ "vitest": "^4.1.0"
80
80
  },
81
81
  "dependencies": {
82
82
  "chalk": "^5.6.2",