@safeaccess/inline 0.1.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.
Files changed (129) hide show
  1. package/.gitattributes +16 -0
  2. package/.gitkeep +0 -0
  3. package/CHANGELOG.md +38 -0
  4. package/LICENSE +21 -0
  5. package/README.md +454 -0
  6. package/benchmarks/get.bench.ts +26 -0
  7. package/benchmarks/parse.bench.ts +41 -0
  8. package/dist/accessors/abstract-accessor.d.ts +213 -0
  9. package/dist/accessors/abstract-accessor.js +294 -0
  10. package/dist/accessors/formats/any-accessor.d.ts +35 -0
  11. package/dist/accessors/formats/any-accessor.js +44 -0
  12. package/dist/accessors/formats/array-accessor.d.ts +26 -0
  13. package/dist/accessors/formats/array-accessor.js +39 -0
  14. package/dist/accessors/formats/env-accessor.d.ts +27 -0
  15. package/dist/accessors/formats/env-accessor.js +64 -0
  16. package/dist/accessors/formats/ini-accessor.d.ts +41 -0
  17. package/dist/accessors/formats/ini-accessor.js +109 -0
  18. package/dist/accessors/formats/json-accessor.d.ts +26 -0
  19. package/dist/accessors/formats/json-accessor.js +56 -0
  20. package/dist/accessors/formats/ndjson-accessor.d.ts +28 -0
  21. package/dist/accessors/formats/ndjson-accessor.js +71 -0
  22. package/dist/accessors/formats/object-accessor.d.ts +48 -0
  23. package/dist/accessors/formats/object-accessor.js +90 -0
  24. package/dist/accessors/formats/xml-accessor.d.ts +27 -0
  25. package/dist/accessors/formats/xml-accessor.js +52 -0
  26. package/dist/accessors/formats/yaml-accessor.d.ts +29 -0
  27. package/dist/accessors/formats/yaml-accessor.js +46 -0
  28. package/dist/contracts/accessors-interface.d.ts +11 -0
  29. package/dist/contracts/accessors-interface.js +1 -0
  30. package/dist/contracts/factory-accessors-interface.d.ts +16 -0
  31. package/dist/contracts/factory-accessors-interface.js +1 -0
  32. package/dist/contracts/parse-integration-interface.d.ts +31 -0
  33. package/dist/contracts/parse-integration-interface.js +1 -0
  34. package/dist/contracts/path-cache-interface.d.ts +40 -0
  35. package/dist/contracts/path-cache-interface.js +1 -0
  36. package/dist/contracts/readable-accessors-interface.d.ts +79 -0
  37. package/dist/contracts/readable-accessors-interface.js +1 -0
  38. package/dist/contracts/security-guard-interface.d.ts +40 -0
  39. package/dist/contracts/security-guard-interface.js +1 -0
  40. package/dist/contracts/security-parser-interface.d.ts +67 -0
  41. package/dist/contracts/security-parser-interface.js +1 -0
  42. package/dist/contracts/writable-accessors-interface.d.ts +65 -0
  43. package/dist/contracts/writable-accessors-interface.js +1 -0
  44. package/dist/core/dot-notation-parser.d.ts +204 -0
  45. package/dist/core/dot-notation-parser.js +343 -0
  46. package/dist/exceptions/accessor-exception.d.ts +13 -0
  47. package/dist/exceptions/accessor-exception.js +16 -0
  48. package/dist/exceptions/invalid-format-exception.d.ts +14 -0
  49. package/dist/exceptions/invalid-format-exception.js +17 -0
  50. package/dist/exceptions/parser-exception.d.ts +14 -0
  51. package/dist/exceptions/parser-exception.js +17 -0
  52. package/dist/exceptions/path-not-found-exception.d.ts +14 -0
  53. package/dist/exceptions/path-not-found-exception.js +17 -0
  54. package/dist/exceptions/readonly-violation-exception.d.ts +15 -0
  55. package/dist/exceptions/readonly-violation-exception.js +18 -0
  56. package/dist/exceptions/security-exception.d.ts +18 -0
  57. package/dist/exceptions/security-exception.js +21 -0
  58. package/dist/exceptions/unsupported-type-exception.d.ts +14 -0
  59. package/dist/exceptions/unsupported-type-exception.js +17 -0
  60. package/dist/exceptions/yaml-parse-exception.d.ts +17 -0
  61. package/dist/exceptions/yaml-parse-exception.js +20 -0
  62. package/dist/index.d.ts +30 -0
  63. package/dist/index.js +30 -0
  64. package/dist/inline.d.ts +402 -0
  65. package/dist/inline.js +512 -0
  66. package/dist/parser/xml-parser.d.ts +46 -0
  67. package/dist/parser/xml-parser.js +288 -0
  68. package/dist/parser/yaml-parser.d.ts +94 -0
  69. package/dist/parser/yaml-parser.js +286 -0
  70. package/dist/security/forbidden-keys.d.ts +34 -0
  71. package/dist/security/forbidden-keys.js +80 -0
  72. package/dist/security/security-guard.d.ts +94 -0
  73. package/dist/security/security-guard.js +172 -0
  74. package/dist/security/security-parser.d.ts +130 -0
  75. package/dist/security/security-parser.js +192 -0
  76. package/dist/type-format.d.ts +28 -0
  77. package/dist/type-format.js +29 -0
  78. package/eslint.config.js +1 -0
  79. package/package.json +39 -0
  80. package/src/accessors/abstract-accessor.ts +353 -0
  81. package/src/accessors/formats/any-accessor.ts +51 -0
  82. package/src/accessors/formats/array-accessor.ts +45 -0
  83. package/src/accessors/formats/env-accessor.ts +79 -0
  84. package/src/accessors/formats/ini-accessor.ts +124 -0
  85. package/src/accessors/formats/json-accessor.ts +66 -0
  86. package/src/accessors/formats/ndjson-accessor.ts +82 -0
  87. package/src/accessors/formats/object-accessor.ts +100 -0
  88. package/src/accessors/formats/xml-accessor.ts +58 -0
  89. package/src/accessors/formats/yaml-accessor.ts +52 -0
  90. package/src/contracts/accessors-interface.ts +12 -0
  91. package/src/contracts/factory-accessors-interface.ts +16 -0
  92. package/src/contracts/parse-integration-interface.ts +32 -0
  93. package/src/contracts/path-cache-interface.ts +43 -0
  94. package/src/contracts/readable-accessors-interface.ts +88 -0
  95. package/src/contracts/security-guard-interface.ts +43 -0
  96. package/src/contracts/security-parser-interface.ts +74 -0
  97. package/src/contracts/writable-accessors-interface.ts +70 -0
  98. package/src/core/dot-notation-parser.ts +419 -0
  99. package/src/exceptions/accessor-exception.ts +16 -0
  100. package/src/exceptions/invalid-format-exception.ts +18 -0
  101. package/src/exceptions/parser-exception.ts +18 -0
  102. package/src/exceptions/path-not-found-exception.ts +18 -0
  103. package/src/exceptions/readonly-violation-exception.ts +19 -0
  104. package/src/exceptions/security-exception.ts +22 -0
  105. package/src/exceptions/unsupported-type-exception.ts +18 -0
  106. package/src/exceptions/yaml-parse-exception.ts +21 -0
  107. package/src/index.ts +46 -0
  108. package/src/inline.ts +570 -0
  109. package/src/parser/xml-parser.ts +334 -0
  110. package/src/parser/yaml-parser.ts +368 -0
  111. package/src/security/forbidden-keys.ts +81 -0
  112. package/src/security/security-guard.ts +195 -0
  113. package/src/security/security-parser.ts +233 -0
  114. package/src/type-format.ts +28 -0
  115. package/stryker.config.json +24 -0
  116. package/tests/accessors/accessors.test.ts +1017 -0
  117. package/tests/accessors/json-accessor.test.ts +171 -0
  118. package/tests/core/dot-notation-parser.test.ts +587 -0
  119. package/tests/exceptions/parser-exception.test.ts +31 -0
  120. package/tests/inline.test.ts +445 -0
  121. package/tests/mocks/fake-parse-integration.ts +24 -0
  122. package/tests/mocks/fake-path-cache.ts +31 -0
  123. package/tests/parity.test.ts +164 -0
  124. package/tests/parser/xml-parser.test.ts +618 -0
  125. package/tests/parser/yaml-parser.test.ts +463 -0
  126. package/tests/security/security-guard.test.ts +646 -0
  127. package/tests/security/security-parser.test.ts +391 -0
  128. package/tsconfig.json +16 -0
  129. package/vitest.config.ts +19 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Stream-wrapper and protocol URI schemes blocked by prefix matching in JavaScript environments.
3
+ *
4
+ * Entries include the delimiter (`://` or `:`) so that legitimate keys sharing
5
+ * the same word prefix (e.g. `node_modules`) are not blocked.
6
+ *
7
+ * PHP-specific wrappers (`phar://`, `php://`, `expect://`, `glob://`, `zlib://`,
8
+ * `ogg://`, `rar://`, `zip://`, `ssh2.tunnel://`) are intentionally absent — they
9
+ * have no meaning in a JavaScript/Node.js runtime.
10
+ *
11
+ * `data:` uses a single-colon delimiter (matching the browser RFC 2397 format
12
+ * `data:mimeType,...`) rather than `data://` (which is the PHP stream-wrapper
13
+ * syntax). Using `data:` as the prefix subsumes `data://` and also catches
14
+ * `data:text/html,<script>...</script>` XSS vectors, consistent with how
15
+ * `javascript:` is handled.
16
+ *
17
+ * @internal
18
+ */
19
+ export const STREAM_WRAPPER_PREFIXES: readonly string[] = [
20
+ 'file://',
21
+ 'http://',
22
+ 'https://',
23
+ 'ftp://',
24
+ 'data:',
25
+ 'javascript:',
26
+ 'blob:',
27
+ 'ws://',
28
+ 'wss://',
29
+ 'node:',
30
+ ];
31
+
32
+ /**
33
+ * Default forbidden keys for JavaScript/TypeScript environments.
34
+ *
35
+ * Stored lowercase for case-insensitive `__*` key lookup. Covers:
36
+ * – prototype pollution vectors (`__proto__`, `constructor`, `prototype`)
37
+ * – legacy prototype manipulation methods (`__defineGetter__` family, stored lowercase)
38
+ * – `hasOwnProperty` shadow (overriding it can bypass guard checks)
39
+ * – JS-relevant stream wrapper / protocol scheme strings as exact-match defence-in-depth
40
+ *
41
+ * PHP magic methods and PHP superglobals are deliberately absent — they are not
42
+ * meaningful in a JavaScript runtime and belong in the PHP package's SecurityGuard only.
43
+ *
44
+ * @internal
45
+ */
46
+ export const DEFAULT_FORBIDDEN_KEYS: ReadonlySet<string> = new Set([
47
+ // Prototype pollution vectors (stored lowercase; __* keys normalised before lookup)
48
+ '__proto__',
49
+ 'constructor',
50
+ 'prototype',
51
+ // JavaScript legacy prototype manipulation (stored lowercase)
52
+ '__definegetter__',
53
+ '__definesetter__',
54
+ '__lookupgetter__',
55
+ '__lookupsetter__',
56
+ // Object.prototype shadow key — overriding it can break hasOwnProperty-based guards
57
+ 'hasOwnProperty',
58
+ // Node.js module-scope path globals — should never appear as data keys to
59
+ // prevent path-injection risks in code that reads them via dynamic property access
60
+ '__dirname',
61
+ '__filename',
62
+ // Stream wrapper and protocol exact entries — also caught by STREAM_WRAPPER_PREFIXES prefix matching.
63
+ // The Set entries below are intentional defence-in-depth: they allow O(1) exact-key
64
+ // lookup before the O(n) prefix loop runs.
65
+ 'file://',
66
+ 'http://',
67
+ 'https://',
68
+ 'ftp://',
69
+ // 'data:' blocks browser RFC 2397 data URIs (data:mimeType,...) which can carry
70
+ // executable HTML/JS payloads (e.g. data:text/html,<script>alert(1)</script>).
71
+ // 'data://' is kept for exact-match parity with the PHP SecurityGuard that uses
72
+ // the PHP stream-wrapper notation; 'data:' prefix in STREAM_WRAPPER_PREFIXES
73
+ // already subsumes it for prefix-based lookups.
74
+ 'data:',
75
+ 'data://',
76
+ 'javascript:',
77
+ 'blob:',
78
+ 'ws://',
79
+ 'wss://',
80
+ 'node:',
81
+ ]);
@@ -0,0 +1,195 @@
1
+ import type { SecurityGuardInterface } from '../contracts/security-guard-interface.js';
2
+ import { SecurityException } from '../exceptions/security-exception.js';
3
+ import { DEFAULT_FORBIDDEN_KEYS, STREAM_WRAPPER_PREFIXES } from './forbidden-keys.js';
4
+
5
+ /**
6
+ * Immutable guard that validates keys against a forbidden-key list.
7
+ *
8
+ * Blocks JavaScript prototype pollution vectors (`__proto__`, `constructor`,
9
+ * `prototype`), legacy prototype manipulation methods (`__defineGetter__` family),
10
+ * the `hasOwnProperty` shadow key, and JavaScript-relevant stream wrapper /
11
+ * protocol URIs (`file://`, `javascript:`, `ws://`, etc.) from being used as
12
+ * data keys. The forbidden list is built at construction time and cannot be
13
+ * modified afterward.
14
+ *
15
+ * `__*` keys are normalised to lowercase before lookup so that case variants
16
+ * such as `__PROTO__` are also blocked.
17
+ *
18
+ * Stream wrapper URIs are matched by prefix so that fully-formed URIs such as
19
+ * `javascript:alert(1)` are also blocked, not only the bare scheme string.
20
+ *
21
+ * @example
22
+ * const guard = new SecurityGuard();
23
+ * guard.assertSafeKey('name'); // OK
24
+ * guard.assertSafeKey('__proto__'); // throws SecurityException
25
+ */
26
+ export class SecurityGuard implements SecurityGuardInterface {
27
+ readonly maxDepth: number;
28
+
29
+ private readonly forbiddenKeysMap: ReadonlySet<string>;
30
+
31
+ /**
32
+ * Build the guard with default forbidden keys plus any extras.
33
+ *
34
+ * @param maxDepth - Maximum recursion depth for recursive key scanning.
35
+ * @param extraForbiddenKeys - Additional keys to forbid beyond defaults.
36
+ */
37
+ constructor(maxDepth: number = 512, /* Stryker disable next-line ArrayDeclaration -- equivalent: default [] produces identical behavior; no extra keys added to Set */ extraForbiddenKeys: string[] = []) {
38
+ this.maxDepth = Number.isFinite(maxDepth) ? maxDepth : 512;
39
+
40
+ /* Stryker disable next-line ConditionalExpression -- equivalent: if (false) still produces the same forbiddenKeysMap for empty arrays since Set(DEFAULT)=DEFAULT */
41
+ if (extraForbiddenKeys.length === 0) {
42
+ this.forbiddenKeysMap = DEFAULT_FORBIDDEN_KEYS;
43
+ } else {
44
+ const combined = new Set(DEFAULT_FORBIDDEN_KEYS);
45
+ for (const key of extraForbiddenKeys) {
46
+ combined.add(key);
47
+ }
48
+ this.forbiddenKeysMap = combined;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check whether a key is in the forbidden list.
54
+ *
55
+ * `__*` keys are normalised to lowercase before the map lookup so that
56
+ * case variants (e.g. `__PROTO__`) are also caught. Stream wrapper and
57
+ * protocol URIs are matched by prefix.
58
+ *
59
+ * @param key - Key name to check.
60
+ * @returns True if the key is forbidden.
61
+ *
62
+ * @example
63
+ * guard.isForbiddenKey('__proto__'); // true
64
+ * guard.isForbiddenKey('name'); // false
65
+ */
66
+ isForbiddenKey(key: string): boolean {
67
+ // Normalise __* keys to lowercase — catches case variants such as __PROTO__.
68
+ const lookupKey = key.startsWith('__') ? key.toLowerCase() : key;
69
+
70
+ if (this.forbiddenKeysMap.has(lookupKey)) {
71
+ return true;
72
+ }
73
+
74
+ const lower = key.toLowerCase();
75
+ for (const prefix of STREAM_WRAPPER_PREFIXES) {
76
+ if (lower.startsWith(prefix)) {
77
+ return true;
78
+ }
79
+ }
80
+
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Assert that a single key is safe, throwing on violation.
86
+ *
87
+ * @param key - Key name to validate.
88
+ * @throws {SecurityException} When the key is in the forbidden list.
89
+ *
90
+ * @example
91
+ * guard.assertSafeKey('username'); // OK
92
+ * guard.assertSafeKey('__proto__'); // throws SecurityException
93
+ */
94
+ assertSafeKey(key: string): void {
95
+ if (this.isForbiddenKey(key)) {
96
+ throw new SecurityException(`Forbidden key '${key}' detected.`);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Recursively assert that all keys in a data structure are safe.
102
+ *
103
+ * @param data - Data to scan for forbidden keys.
104
+ * @param depth - Current recursion depth.
105
+ * @throws {SecurityException} When a forbidden key is found or depth exceeds the limit.
106
+ *
107
+ * @example
108
+ * guard.assertSafeKeys({ name: 'Alice', age: 30 }); // OK
109
+ * guard.assertSafeKeys({ __proto__: 'bad' }); // throws SecurityException
110
+ */
111
+ assertSafeKeys(data: unknown, depth: number = 0): void {
112
+ if (typeof data !== 'object' || data === null) {
113
+ return;
114
+ }
115
+
116
+ /* Stryker disable next-line EqualityOperator -- boundary: depth > max vs depth >= max; ≥ would throw one level too early */
117
+ if (depth > this.maxDepth) {
118
+ throw new SecurityException(
119
+ `Recursion depth ${depth} exceeds maximum of ${this.maxDepth}.`,
120
+ );
121
+ }
122
+
123
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
124
+ this.assertSafeKey(key);
125
+ this.assertSafeKeys(value, depth + 1);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Remove all forbidden keys from a data structure recursively.
131
+ *
132
+ * @param data - Data to sanitize.
133
+ * @param depth - Current recursion depth.
134
+ * @returns Sanitized data without forbidden keys.
135
+ * @throws {SecurityException} When recursion depth exceeds the limit.
136
+ *
137
+ * @example
138
+ * guard.sanitize({ name: 'Alice', __construct: 'bad' });
139
+ * // => { name: 'Alice' }
140
+ */
141
+ sanitize(data: Record<string, unknown>, depth: number = 0): Record<string, unknown> {
142
+ /* Stryker disable next-line EqualityOperator -- boundary: depth > max vs depth >= max; >= would throw one level too early */
143
+ if (depth > this.maxDepth) {
144
+ throw new SecurityException(
145
+ `Recursion depth ${depth} exceeds maximum of ${this.maxDepth}.`,
146
+ );
147
+ }
148
+
149
+ const result: Record<string, unknown> = {};
150
+
151
+ for (const [key, value] of Object.entries(data)) {
152
+ if (this.isForbiddenKey(key)) {
153
+ continue;
154
+ }
155
+
156
+ if (Array.isArray(value)) {
157
+ result[key] = this.sanitizeArray(value, depth + 1);
158
+ } else if (typeof value === 'object' && value !== null) {
159
+ result[key] = this.sanitize(value as Record<string, unknown>, depth + 1);
160
+ } else {
161
+ result[key] = value;
162
+ }
163
+ }
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Recursively sanitize array elements, removing forbidden keys from nested objects.
170
+ *
171
+ * @param array - Array to sanitize.
172
+ * @param depth - Current recursion depth.
173
+ * @returns Sanitized array with forbidden keys removed from object elements.
174
+ *
175
+ * @throws {SecurityException} When recursion depth exceeds the limit.
176
+ */
177
+ private sanitizeArray(array: unknown[], depth: number): unknown[] {
178
+ /* Stryker disable next-line EqualityOperator -- boundary: depth > max vs depth >= max; >= would throw one level too early */
179
+ if (depth > this.maxDepth) {
180
+ throw new SecurityException(
181
+ `Recursion depth ${depth} exceeds maximum of ${this.maxDepth}.`,
182
+ );
183
+ }
184
+
185
+ return array.map((item) => {
186
+ if (Array.isArray(item)) {
187
+ return this.sanitizeArray(item, depth + 1);
188
+ }
189
+ if (typeof item === 'object' && item !== null) {
190
+ return this.sanitize(item as Record<string, unknown>, depth + 1);
191
+ }
192
+ return item;
193
+ });
194
+ }
195
+ }
@@ -0,0 +1,233 @@
1
+ import type { SecurityParserInterface } from '../contracts/security-parser-interface.js';
2
+ import { SecurityException } from '../exceptions/security-exception.js';
3
+
4
+ /**
5
+ * Enforce structural security constraints on parsed data.
6
+ *
7
+ * Validates payload size, maximum key count, recursion depth, and
8
+ * structural depth limits.
9
+ *
10
+ * @example
11
+ * const parser = new SecurityParser({ maxDepth: 10, maxKeys: 100 });
12
+ * parser.assertPayloadSize('{"key":"value"}');
13
+ */
14
+ export class SecurityParser implements SecurityParserInterface {
15
+ readonly maxDepth: number;
16
+ readonly maxPayloadBytes: number;
17
+ readonly maxKeys: number;
18
+ readonly maxCountRecursiveDepth: number;
19
+ readonly maxResolveDepth: number;
20
+
21
+ /**
22
+ * Build security options from configuration values.
23
+ *
24
+ * @param options - Configuration overrides.
25
+ * @param options.maxDepth - Maximum allowed structural nesting depth. Default: 512.
26
+ * @param options.maxPayloadBytes - Maximum allowed raw payload size in bytes. Default: 10 MB.
27
+ * @param options.maxKeys - Maximum total number of keys across the entire structure. Default: 10000.
28
+ * This value is also passed to `XmlParser` as the element-count cap for the Node.js manual XML
29
+ * parser path. Setting it below a document's element count will cause `fromXml()` to throw
30
+ * `SecurityException`. Non-positive or non-finite values disable that guard — prefer the default.
31
+ * @param options.maxCountRecursiveDepth - Maximum recursion depth when counting keys. Default: 100.
32
+ * @param options.maxResolveDepth - Maximum recursion depth for path resolution. Default: 100.
33
+ */
34
+ constructor(
35
+ options: {
36
+ maxDepth?: number;
37
+ maxPayloadBytes?: number;
38
+ maxKeys?: number;
39
+ maxCountRecursiveDepth?: number;
40
+ maxResolveDepth?: number;
41
+ } = {},
42
+ ) {
43
+ this.maxDepth = SecurityParser.clampOption(options.maxDepth, 512);
44
+ this.maxPayloadBytes = SecurityParser.clampOption(options.maxPayloadBytes, 10 * 1024 * 1024);
45
+ this.maxKeys = SecurityParser.clampOption(options.maxKeys, 10_000);
46
+ this.maxCountRecursiveDepth = SecurityParser.clampOption(options.maxCountRecursiveDepth, 100);
47
+ this.maxResolveDepth = SecurityParser.clampOption(options.maxResolveDepth, 100);
48
+ }
49
+
50
+ /**
51
+ * Assert that a raw string payload does not exceed the byte limit.
52
+ *
53
+ * @param input - Raw input string to measure.
54
+ * @param maxBytes - Override limit, or undefined to use configured default.
55
+ * @throws {SecurityException} When the payload exceeds the limit.
56
+ *
57
+ * @example
58
+ * parser.assertPayloadSize('small input'); // OK
59
+ */
60
+ assertPayloadSize(input: string, maxBytes?: number): void {
61
+ const limit = maxBytes ?? this.maxPayloadBytes;
62
+ const size = new TextEncoder().encode(input).length;
63
+
64
+ if (size > limit) {
65
+ throw new SecurityException(
66
+ `Payload size ${size} bytes exceeds maximum of ${limit} bytes.`,
67
+ );
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Assert that resolve depth does not exceed the configured limit.
73
+ *
74
+ * @param depth - Current depth counter.
75
+ * @throws {SecurityException} When depth exceeds the maximum.
76
+ *
77
+ * @example
78
+ * parser.assertMaxResolveDepth(5); // OK
79
+ */
80
+ assertMaxResolveDepth(depth: number): void {
81
+ if (depth > this.maxResolveDepth) {
82
+ throw new SecurityException(
83
+ `Deep merge exceeded maximum depth of ${this.maxResolveDepth}`,
84
+ );
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Assert that total key count does not exceed the limit.
90
+ *
91
+ * @param data - Data to count keys in.
92
+ * @param maxKeys - Override limit, or undefined to use configured default.
93
+ * @param maxCountDepth - Override recursion depth limit, or undefined for default.
94
+ * @throws {SecurityException} When key count exceeds the limit.
95
+ *
96
+ * @example
97
+ * parser.assertMaxKeys({ a: 1, b: 2 }); // OK
98
+ */
99
+ assertMaxKeys(data: Record<string, unknown>, maxKeys?: number, maxCountDepth?: number): void {
100
+ const limit = maxKeys ?? this.maxKeys;
101
+ const count = this.countKeys(data, 0, maxCountDepth ?? this.maxCountRecursiveDepth);
102
+
103
+ if (count > limit) {
104
+ throw new SecurityException(
105
+ `Data contains ${count} keys, exceeding maximum of ${limit}.`,
106
+ );
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Assert that current recursion depth does not exceed the limit.
112
+ *
113
+ * @param currentDepth - Current depth counter.
114
+ * @param maxDepth - Override limit, or undefined to use configured default.
115
+ * @throws {SecurityException} When the depth exceeds the limit.
116
+ *
117
+ * @example
118
+ * parser.assertMaxDepth(3); // OK
119
+ */
120
+ assertMaxDepth(currentDepth: number, maxDepth?: number): void {
121
+ const limit = maxDepth ?? this.maxDepth;
122
+ if (currentDepth > limit) {
123
+ throw new SecurityException(
124
+ `Recursion depth ${currentDepth} exceeds maximum of ${limit}.`,
125
+ );
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Assert that structural nesting depth does not exceed the policy limit.
131
+ *
132
+ * @param data - Data to measure structural depth of.
133
+ * @param maxDepth - Maximum allowed structural depth.
134
+ * @throws {SecurityException} When structural depth exceeds the limit.
135
+ *
136
+ * @example
137
+ * parser.assertMaxStructuralDepth({ a: { b: 1 } }, 10); // OK
138
+ */
139
+ assertMaxStructuralDepth(data: unknown, maxDepth: number): void {
140
+ const depth = this.measureDepth(data, 0, maxDepth + 1);
141
+ if (depth > maxDepth) {
142
+ throw new SecurityException(
143
+ `Data structural depth ${depth} exceeds policy maximum of ${maxDepth}.`,
144
+ );
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Return the configured maximum structural nesting depth.
150
+ *
151
+ * @returns Maximum allowed depth.
152
+ */
153
+ getMaxDepth(): number {
154
+ return this.maxDepth;
155
+ }
156
+
157
+ /**
158
+ * Return the configured maximum path-resolve recursion depth.
159
+ *
160
+ * @returns Maximum allowed resolve depth.
161
+ */
162
+ getMaxResolveDepth(): number {
163
+ return this.maxResolveDepth;
164
+ }
165
+
166
+ /**
167
+ * Return the configured maximum total key count.
168
+ *
169
+ * @returns Maximum allowed key count.
170
+ */
171
+ getMaxKeys(): number {
172
+ return this.maxKeys;
173
+ }
174
+
175
+ /**
176
+ * Recursively count keys in a data structure.
177
+ *
178
+ * @param obj - Data to count keys in.
179
+ * @param depth - Current recursion depth.
180
+ * @param maxDepth - Maximum recursion depth for counting.
181
+ * @returns Total number of keys found.
182
+ */
183
+ private countKeys(obj: unknown, depth: number, maxDepth: number): number {
184
+ if (depth > maxDepth) {
185
+ return 0;
186
+ }
187
+
188
+ if (typeof obj !== 'object' || obj === null) {
189
+ return 0;
190
+ }
191
+
192
+ const entries = Object.entries(obj as Record<string, unknown>);
193
+ let count = entries.length;
194
+
195
+ for (const [, value] of entries) {
196
+ count += this.countKeys(value, depth + 1, maxDepth);
197
+ }
198
+
199
+ return count;
200
+ }
201
+
202
+ /**
203
+ * Recursively measure the maximum nesting depth of a data structure.
204
+ *
205
+ * @param value - Data to measure.
206
+ * @param current - Current depth counter.
207
+ * @param maxDepth - Ceiling to stop measuring.
208
+ * @returns Maximum depth found.
209
+ */
210
+ private measureDepth(value: unknown, current: number, maxDepth: number): number {
211
+ /* Stryker disable next-line ConditionalExpression,EqualityOperator -- equivalent: >= vs > at ceiling; both correctly stop at maxDepth */
212
+ if (current >= maxDepth || typeof value !== 'object' || value === null) {
213
+ return current;
214
+ }
215
+
216
+ let max = current;
217
+
218
+ for (const child of Object.values(value as Record<string, unknown>)) {
219
+ const d = this.measureDepth(child, current + 1, maxDepth);
220
+ /* Stryker disable next-line ConditionalExpression,EqualityOperator -- equivalent: d > max vs d >= max; assigning d when d===max is a no-op */
221
+ if (d > max) {
222
+ max = d;
223
+ }
224
+ }
225
+
226
+ return max;
227
+ }
228
+
229
+ private static clampOption(value: number | undefined, defaultValue: number): number {
230
+ /* Stryker disable next-line ConditionalExpression -- equivalent: Number.isFinite covers undefined (isFinite(undefined)===false); simplest safe form */
231
+ return Number.isFinite(value) ? (value as number) : defaultValue;
232
+ }
233
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Enumeration of supported data format types.
3
+ *
4
+ * Used by {@link Inline.from} to select the appropriate accessor.
5
+ *
6
+ * @example
7
+ * const accessor = Inline.from(TypeFormat.Json, '{"key":"value"}');
8
+ */
9
+ export enum TypeFormat {
10
+ /** Plain array or object data. */
11
+ Array = 'array',
12
+ /** JavaScript object (class instance or plain). */
13
+ Object = 'object',
14
+ /** JSON string. */
15
+ Json = 'json',
16
+ /** XML string. */
17
+ Xml = 'xml',
18
+ /** YAML string. */
19
+ Yaml = 'yaml',
20
+ /** INI configuration string. */
21
+ Ini = 'ini',
22
+ /** Dotenv-formatted string. */
23
+ Env = 'env',
24
+ /** Newline-delimited JSON string. */
25
+ Ndjson = 'ndjson',
26
+ /** Auto-detected format via integration. */
27
+ Any = 'any',
28
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "testRunner": "vitest",
3
+ "mutate": [
4
+ "src/**/*.ts",
5
+ "!src/**/*.test.ts",
6
+ "!src/index.ts",
7
+ "!src/contracts/**/*.ts",
8
+ "!src/exceptions/**/*.ts",
9
+ "!src/security/forbidden-keys.ts",
10
+ "!src/parser/yaml-parser.ts",
11
+ "!src/parser/xml-parser.ts"
12
+ ],
13
+ "thresholds": {
14
+ "high": 100,
15
+ "low": 100,
16
+ "break": 100
17
+ },
18
+ "timeoutMS": 60000,
19
+ "concurrency": 4,
20
+ "reporters": ["html", "clear-text", "progress"],
21
+ "htmlReporter": {
22
+ "fileName": "reports/stryker/mutation.html"
23
+ }
24
+ }