@contrast/assess 1.78.0 → 1.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/dataflow/propagation/index.js +2 -0
- package/lib/dataflow/propagation/install/JSON/stringify.js +14 -141
- package/lib/dataflow/propagation/install/JSON/tag-ranges.js +174 -0
- package/lib/dataflow/propagation/install/jsonwebtoken.js +1 -1
- package/lib/dataflow/propagation/install/sanitize-html.js +1 -1
- package/lib/dataflow/propagation/install/url/url.js +56 -22
- package/lib/dataflow/propagation/install/util-format.js +43 -3
- package/lib/dataflow/propagation/install/zod/matrix.js +149 -0
- package/lib/dataflow/propagation/install/zod/process-result-v3.js +228 -0
- package/lib/dataflow/propagation/install/zod/process-result-v4.js +191 -0
- package/lib/dataflow/propagation/install/zod/utils.js +227 -0
- package/lib/dataflow/propagation/install/zod/v3.js +117 -0
- package/lib/dataflow/propagation/install/zod/v4.js +97 -0
- package/lib/dataflow/sources/handler.js +1 -1
- package/lib/dataflow/sources/install/@sap.js +14 -2
- package/lib/dataflow/sources/install/fastify-websocket.js +1 -1
- package/lib/dataflow/sources/install/socket.io.js +1 -1
- package/lib/event-factory.js +1 -1
- package/lib/get-source-context.js +1 -1
- package/lib/index.js +1 -1
- package/lib/make-source-context.js +1 -1
- package/lib/policy.js +1 -1
- package/package.json +12 -12
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2026 Contrast Security, Inc
|
|
3
|
+
* Contact: support@contrastsecurity.com
|
|
4
|
+
* License: Commercial
|
|
5
|
+
|
|
6
|
+
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
+
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
+
* made available through public repositories, use of this Software is subject to
|
|
9
|
+
* the applicable End User Licensing Agreement found at
|
|
10
|
+
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
+
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
+
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
+
* way not consistent with the End User License Agreement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
DataflowTag: { STRING_TYPE_CHECKED, ALPHANUM_SPACE_HYPHEN, LIMITED_CHARS, CUSTOM_VALIDATED },
|
|
20
|
+
} = require('@contrast/common');
|
|
21
|
+
|
|
22
|
+
// check-kind names that zod v3 (a flat `check.kind` string) and zod v4 (a
|
|
23
|
+
// `check._zod.def.format` string, when `check._zod.def.check ===
|
|
24
|
+
// 'string_format'`) happen to spell identically, with the same tag - shared
|
|
25
|
+
// so neither version's table has to repeat these entries. Anything either
|
|
26
|
+
// version names or tags differently lives in that version's own table below
|
|
27
|
+
// instead of being forced into this shared shape.
|
|
28
|
+
const COMMON_CHECK_MATRIX = {
|
|
29
|
+
includes: STRING_TYPE_CHECKED,
|
|
30
|
+
url: STRING_TYPE_CHECKED,
|
|
31
|
+
emoji: STRING_TYPE_CHECKED,
|
|
32
|
+
|
|
33
|
+
uuid: ALPHANUM_SPACE_HYPHEN,
|
|
34
|
+
cuid: ALPHANUM_SPACE_HYPHEN,
|
|
35
|
+
cuid2: ALPHANUM_SPACE_HYPHEN,
|
|
36
|
+
ulid: ALPHANUM_SPACE_HYPHEN,
|
|
37
|
+
nanoid: ALPHANUM_SPACE_HYPHEN,
|
|
38
|
+
jwt: ALPHANUM_SPACE_HYPHEN,
|
|
39
|
+
base64: ALPHANUM_SPACE_HYPHEN,
|
|
40
|
+
base64url: ALPHANUM_SPACE_HYPHEN,
|
|
41
|
+
|
|
42
|
+
email: LIMITED_CHARS,
|
|
43
|
+
date: LIMITED_CHARS,
|
|
44
|
+
datetime: LIMITED_CHARS,
|
|
45
|
+
time: LIMITED_CHARS,
|
|
46
|
+
duration: LIMITED_CHARS,
|
|
47
|
+
|
|
48
|
+
regex: CUSTOM_VALIDATED,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const v3 = {
|
|
52
|
+
// zod v3's checks are a flat array on ZodString's own def (`_def.checks`),
|
|
53
|
+
// each a plain `{kind: '...', ...}` object - no nested format/check split
|
|
54
|
+
// like v4's `_zod.def.check`/`format`, so one flat table keyed by
|
|
55
|
+
// `check.kind` covers every string check. `nonempty` is deliberately
|
|
56
|
+
// absent - `ZodString.nonempty()` compiles to `.min(1)`, there's no
|
|
57
|
+
// distinct 'nonempty' check kind to key off of.
|
|
58
|
+
CHECK_KIND_MATRIX: {
|
|
59
|
+
...COMMON_CHECK_MATRIX,
|
|
60
|
+
min: STRING_TYPE_CHECKED,
|
|
61
|
+
max: STRING_TYPE_CHECKED,
|
|
62
|
+
length: STRING_TYPE_CHECKED,
|
|
63
|
+
startsWith: STRING_TYPE_CHECKED,
|
|
64
|
+
endsWith: STRING_TYPE_CHECKED,
|
|
65
|
+
|
|
66
|
+
ip: LIMITED_CHARS,
|
|
67
|
+
cidr: LIMITED_CHARS,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// check.kind values that mutate the string in place (produce a NEW string
|
|
71
|
+
// identity inside zod) rather than just validating it - handled by the
|
|
72
|
+
// retrack path in process-result-v3.js, not tagged via the matrix above.
|
|
73
|
+
// v3 has no validating `lowercase`/`uppercase` checks like v4 - only these
|
|
74
|
+
// mutating ones.
|
|
75
|
+
OVERWRITE_CHECK_KINDS: new Set(['trim', 'toLowerCase', 'toUpperCase']),
|
|
76
|
+
|
|
77
|
+
// schema._def.typeName values whose successful validation should untrack
|
|
78
|
+
// the value entirely, rather than tag it - it's now constrained to a fixed
|
|
79
|
+
// allow-list of values. Unlike v4 (which collapses nativeEnum into 'enum'),
|
|
80
|
+
// v3 keeps ZodNativeEnum as a distinct typeName.
|
|
81
|
+
UNTRACKING_SCHEMA_TYPES: new Set(['ZodEnum', 'ZodNativeEnum', 'ZodLiteral']),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const v4 = {
|
|
85
|
+
// `.email()`, `.uuid()`, `.url()`, `.ipv4()`, etc. all share the same underlying
|
|
86
|
+
// check class ($ZodCheckStringFormat) and are only discriminated by
|
|
87
|
+
// `check._zod.def.format`, not by `check._zod.def.check` (which is the fixed
|
|
88
|
+
// string 'string_format' for all of them). `.includes()`/`.startsWith()`/
|
|
89
|
+
// `.endsWith()`/`.regex()`/`.lowercase()`/`.uppercase()` are format checks too.
|
|
90
|
+
//
|
|
91
|
+
// keyed by check._zod.def.format, when check._zod.def.check === 'string_format'
|
|
92
|
+
FORMAT_MATRIX: {
|
|
93
|
+
...COMMON_CHECK_MATRIX,
|
|
94
|
+
ends_with: STRING_TYPE_CHECKED,
|
|
95
|
+
lowercase: STRING_TYPE_CHECKED,
|
|
96
|
+
starts_with: STRING_TYPE_CHECKED,
|
|
97
|
+
uppercase: STRING_TYPE_CHECKED,
|
|
98
|
+
|
|
99
|
+
guid: ALPHANUM_SPACE_HYPHEN,
|
|
100
|
+
hex: ALPHANUM_SPACE_HYPHEN,
|
|
101
|
+
ksuid: ALPHANUM_SPACE_HYPHEN,
|
|
102
|
+
xid: ALPHANUM_SPACE_HYPHEN,
|
|
103
|
+
|
|
104
|
+
// hash("<algorithm>")
|
|
105
|
+
md5_hex: ALPHANUM_SPACE_HYPHEN,
|
|
106
|
+
md5_base64: ALPHANUM_SPACE_HYPHEN,
|
|
107
|
+
md5_base64url: ALPHANUM_SPACE_HYPHEN,
|
|
108
|
+
sha1_hex: ALPHANUM_SPACE_HYPHEN,
|
|
109
|
+
sha1_base64: ALPHANUM_SPACE_HYPHEN,
|
|
110
|
+
sha1_base64url: ALPHANUM_SPACE_HYPHEN,
|
|
111
|
+
sha256_hex: ALPHANUM_SPACE_HYPHEN,
|
|
112
|
+
sha256_base64: ALPHANUM_SPACE_HYPHEN,
|
|
113
|
+
sha256_base64url: ALPHANUM_SPACE_HYPHEN,
|
|
114
|
+
sha384_hex: ALPHANUM_SPACE_HYPHEN,
|
|
115
|
+
sha384_base64: ALPHANUM_SPACE_HYPHEN,
|
|
116
|
+
sha384_base64url: ALPHANUM_SPACE_HYPHEN,
|
|
117
|
+
sha512_hex: ALPHANUM_SPACE_HYPHEN,
|
|
118
|
+
sha512_base64: ALPHANUM_SPACE_HYPHEN,
|
|
119
|
+
sha512_base64url: ALPHANUM_SPACE_HYPHEN,
|
|
120
|
+
|
|
121
|
+
cidrv4: LIMITED_CHARS,
|
|
122
|
+
cidrv6: LIMITED_CHARS,
|
|
123
|
+
e164: LIMITED_CHARS,
|
|
124
|
+
ipv4: LIMITED_CHARS,
|
|
125
|
+
ipv6: LIMITED_CHARS,
|
|
126
|
+
mac: LIMITED_CHARS,
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// keyed by check._zod.def.check, when check !== 'string_format'. Plain
|
|
130
|
+
// .min()/.max()/.length() get their own distinct check identifiers.
|
|
131
|
+
// 'overwrite' (.trim()/.toLowerCase()/.toUpperCase()/.normalize()/.slugify())
|
|
132
|
+
// and 'custom' (refine/superRefine/check(fn)) are deliberately absent here -
|
|
133
|
+
// 'overwrite' is handled by the retrack path and 'custom' by the
|
|
134
|
+
// trust_custom_validators-gated path in process-result-v4.js, neither is
|
|
135
|
+
// tagged via this ungated matrix.
|
|
136
|
+
CHECK_TYPE_MATRIX: {
|
|
137
|
+
min_length: STRING_TYPE_CHECKED,
|
|
138
|
+
max_length: STRING_TYPE_CHECKED,
|
|
139
|
+
length_equals: STRING_TYPE_CHECKED,
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// schema._zod.def.type values whose successful validation should untrack the
|
|
143
|
+
// value entirely, rather than tag it - it's now constrained to a fixed
|
|
144
|
+
// allow-list of values. zod v4 has no separate 'nativeEnum' schema type;
|
|
145
|
+
// z.nativeEnum() compiles to the same 'enum' type.
|
|
146
|
+
UNTRACKING_SCHEMA_TYPES: new Set(['enum', 'literal']),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
module.exports = { v3, v4 };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2026 Contrast Security, Inc
|
|
3
|
+
* Contact: support@contrastsecurity.com
|
|
4
|
+
* License: Commercial
|
|
5
|
+
|
|
6
|
+
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
+
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
+
* made available through public repositories, use of this Software is subject to
|
|
9
|
+
* the applicable End User Licensing Agreement found at
|
|
10
|
+
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
+
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
+
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
+
* way not consistent with the End User License Agreement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
DataflowTag: { STRING_TYPE_CHECKED },
|
|
20
|
+
isNonEmptyObject,
|
|
21
|
+
} = require('@contrast/common');
|
|
22
|
+
const { CHECK_KIND_MATRIX, OVERWRITE_CHECK_KINDS, UNTRACKING_SCHEMA_TYPES } = require('./matrix').v3;
|
|
23
|
+
|
|
24
|
+
// schema._def.typeName values that pass the value through to `_def.innerType`
|
|
25
|
+
// completely unchanged - safe to unwrap transparently when looking for the
|
|
26
|
+
// concrete leaf schema that actually carries checks. ZodBranded is NOT here -
|
|
27
|
+
// it uses `_def.type` instead of `_def.innerType` for its wrapped schema.
|
|
28
|
+
const INNER_TYPE_WRAPPERS = new Set(['ZodOptional', 'ZodNullable', 'ZodDefault', 'ZodCatch', 'ZodReadonly']);
|
|
29
|
+
const TYPE_KEY_WRAPPERS = new Set(['ZodBranded']);
|
|
30
|
+
// backstop against a self-referential `z.lazy(() => schema)` spinning forever
|
|
31
|
+
const MAX_UNWRAP_DEPTH = 100;
|
|
32
|
+
|
|
33
|
+
module.exports = function(core) {
|
|
34
|
+
const { logger } = core;
|
|
35
|
+
const {
|
|
36
|
+
applyTags,
|
|
37
|
+
untrackValue,
|
|
38
|
+
retrackOverwrite,
|
|
39
|
+
tagCustomValidated,
|
|
40
|
+
handleTransformLike,
|
|
41
|
+
hasAnyTrackedValue,
|
|
42
|
+
getPropagatorContext,
|
|
43
|
+
} = require('./utils')(core);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Unwraps wrapper schema types (optional/nullable/default/branded/lazy/...)
|
|
47
|
+
* to find the schema that actually carries checks. `.catch()` is included
|
|
48
|
+
* here: on the SUCCESS path (the only path this instrumentation ever sees,
|
|
49
|
+
* since the parse-family hooks only fire on success), a `.catch()`
|
|
50
|
+
* schema's result is either the inner schema's own validated value, or a
|
|
51
|
+
* fallback substituted by user code - in the fallback case
|
|
52
|
+
* `tracker.getData(result)` naturally finds nothing tracked (a fresh
|
|
53
|
+
* literal), so no special-casing is needed.
|
|
54
|
+
*/
|
|
55
|
+
function resolveConcreteSchema(schema) {
|
|
56
|
+
let current = schema;
|
|
57
|
+
for (let depth = 0; depth < MAX_UNWRAP_DEPTH && current?._def; depth++) {
|
|
58
|
+
const { typeName } = current._def;
|
|
59
|
+
if (INNER_TYPE_WRAPPERS.has(typeName)) {
|
|
60
|
+
current = current._def.innerType;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (TYPE_KEY_WRAPPERS.has(typeName)) {
|
|
64
|
+
current = current._def.type;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (typeName === 'ZodLazy') {
|
|
68
|
+
current = current._def.getter();
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
return current;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collects every DataflowTag that applies to a concrete (already-unwrapped)
|
|
78
|
+
* schema node. String-family schemas always get a baseline
|
|
79
|
+
* STRING_TYPE_CHECKED (mirroring joi's string.validate() tagging every
|
|
80
|
+
* successfully validated string, regardless of additional rules), on top
|
|
81
|
+
* of whichever more specific tags its chained checks add. Unlike v4, v3
|
|
82
|
+
* has no per-node format schema (no top-level `z.email()`), so every check
|
|
83
|
+
* is an entry in `def.checks`, never the node's own def.
|
|
84
|
+
*/
|
|
85
|
+
function checksToTags(def) {
|
|
86
|
+
const tags = new Set();
|
|
87
|
+
if (def.typeName === 'ZodString') tags.add(STRING_TYPE_CHECKED);
|
|
88
|
+
|
|
89
|
+
for (const check of def.checks ?? []) {
|
|
90
|
+
const tag = CHECK_KIND_MATRIX[check.kind];
|
|
91
|
+
if (tag) tags.add(tag);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return tags;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasOverwriteCheck(def) {
|
|
98
|
+
return (def.checks ?? []).some((check) => OVERWRITE_CHECK_KINDS.has(check.kind));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Writes a walked value back into its container, guarding against
|
|
103
|
+
* `.readonly()` schemas: zod freezes the object/array on the success path
|
|
104
|
+
* (`Object.freeze`), so writing back into it throws in strict mode. Skip
|
|
105
|
+
* the write when nothing changed (the common case) or when the container
|
|
106
|
+
* is frozen - in the frozen case, retracking (e.g. a nested `.trim()`)
|
|
107
|
+
* can't be reflected in the output, which is an accepted degradation.
|
|
108
|
+
*/
|
|
109
|
+
function assignWalked(container, key, walked) {
|
|
110
|
+
if (container[key] === walked) return;
|
|
111
|
+
if (Object.isFrozen(container)) return;
|
|
112
|
+
container[key] = walked;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Handles a ZodEffects node - the schema shape `.refine()`, `.superRefine()`,
|
|
117
|
+
* `.transform()`, and `.preprocess()` all compile to.
|
|
118
|
+
*
|
|
119
|
+
* Unlike v4 (where `.refine()`/`.superRefine()` append a 'custom' check
|
|
120
|
+
* onto the SAME schema node), v3's `.refine()`/`.superRefine()` return a
|
|
121
|
+
* NEW ZodEffects WRAPPING the original schema (`def.schema`). A naive port
|
|
122
|
+
* that only gated CUSTOM_VALIDATED on this node would silently drop
|
|
123
|
+
* STRING_TYPE_CHECKED/format tags and any retracking for every
|
|
124
|
+
* `.refine()`/`.superRefine()` call - so the inner schema is walked FIRST,
|
|
125
|
+
* and CUSTOM_VALIDATED is layered on top of whatever that walk produced.
|
|
126
|
+
* The refinement function's return value is ignored by zod itself, so
|
|
127
|
+
* `result` here is exactly whatever the inner schema's own parse produced.
|
|
128
|
+
*
|
|
129
|
+
* `.preprocess()` is out of v1 scope: the pre-preprocess value is a
|
|
130
|
+
* different, unobservable input than what we receive here.
|
|
131
|
+
*/
|
|
132
|
+
function handleEffects(concrete, def, input, result, path) {
|
|
133
|
+
if (def.effect.type === 'refinement') {
|
|
134
|
+
const walked = walk(def.schema, input, result, path);
|
|
135
|
+
return tagCustomValidated({
|
|
136
|
+
sourceValue: walked,
|
|
137
|
+
targetValue: walked,
|
|
138
|
+
schema: concrete,
|
|
139
|
+
methodName: 'refine',
|
|
140
|
+
retrack: false,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (def.effect.type === 'transform') {
|
|
145
|
+
return handleTransformLike(def.schema, input, result);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Recursively walks a schema alongside its pre-parse input and post-parse
|
|
153
|
+
* result, tagging/untracking/retracking tracked strings it finds. Returns
|
|
154
|
+
* the (possibly retracked) result value - the caller must use this return
|
|
155
|
+
* value, since retracking (trim/toLowerCase/transform) produces a NEW
|
|
156
|
+
* string object that the original caller of `.parse()` needs to actually
|
|
157
|
+
* receive in order for the tag to travel with it.
|
|
158
|
+
*/
|
|
159
|
+
function walk(schema, input, result, path) {
|
|
160
|
+
const concrete = resolveConcreteSchema(schema);
|
|
161
|
+
if (!concrete?._def) return result;
|
|
162
|
+
const def = concrete._def;
|
|
163
|
+
|
|
164
|
+
if (UNTRACKING_SCHEMA_TYPES.has(def.typeName)) {
|
|
165
|
+
untrackValue(result);
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (def.typeName === 'ZodEffects') {
|
|
170
|
+
return handleEffects(concrete, def, input, result, path);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (def.typeName === 'ZodPipeline') {
|
|
174
|
+
return handleTransformLike(def.in, input, result);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof result === 'string') {
|
|
178
|
+
let taggedResult = result;
|
|
179
|
+
if (hasOverwriteCheck(def)) {
|
|
180
|
+
taggedResult = retrackOverwrite(input, result, concrete);
|
|
181
|
+
}
|
|
182
|
+
applyTags(taggedResult, checksToTags(def), concrete, 'string');
|
|
183
|
+
return taggedResult;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
switch (def.typeName) {
|
|
187
|
+
case 'ZodObject':
|
|
188
|
+
if (isNonEmptyObject(input) && isNonEmptyObject(result)) {
|
|
189
|
+
for (const key of Object.keys(concrete.shape)) {
|
|
190
|
+
assignWalked(result, key, walk(concrete.shape[key], input[key], result[key], [...path, key]));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
case 'ZodArray':
|
|
195
|
+
if (Array.isArray(result)) {
|
|
196
|
+
result.forEach((value, i) => {
|
|
197
|
+
assignWalked(result, i, walk(concrete.element, input?.[i], value, [...path, i]));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
default:
|
|
202
|
+
// unions/discriminatedUnion/intersection/tuples/records/maps/sets/
|
|
203
|
+
// promises/functions are deliberately out of v1 scope, mirroring v4.
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Entry point called from the safeParse-family post-hook. Bails out
|
|
210
|
+
* cheaply unless something in `input` or `result` is actually tracked,
|
|
211
|
+
* mirroring joi's early-return checks. Returns the (possibly retracked)
|
|
212
|
+
* result - callers must use the return value as the definitive result
|
|
213
|
+
* going forward.
|
|
214
|
+
*/
|
|
215
|
+
function handleParseSuccess(schema, input, result) {
|
|
216
|
+
try {
|
|
217
|
+
if (!getPropagatorContext()) return result;
|
|
218
|
+
if (!hasAnyTrackedValue(input) && !hasAnyTrackedValue(result)) return result;
|
|
219
|
+
|
|
220
|
+
return walk(schema, input, result, []);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
logger.error({ err }, 'zod v3 parse propagation walk failed');
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { handleParseSuccess };
|
|
228
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2026 Contrast Security, Inc
|
|
3
|
+
* Contact: support@contrastsecurity.com
|
|
4
|
+
* License: Commercial
|
|
5
|
+
|
|
6
|
+
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
+
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
+
* made available through public repositories, use of this Software is subject to
|
|
9
|
+
* the applicable End User Licensing Agreement found at
|
|
10
|
+
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
+
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
+
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
+
* way not consistent with the End User License Agreement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
DataflowTag: { STRING_TYPE_CHECKED },
|
|
20
|
+
isNonEmptyObject,
|
|
21
|
+
} = require('@contrast/common');
|
|
22
|
+
const { CHECK_TYPE_MATRIX, FORMAT_MATRIX, UNTRACKING_SCHEMA_TYPES } = require('./matrix').v4;
|
|
23
|
+
|
|
24
|
+
// schema._zod.def.type values that pass the value through to `innerType`
|
|
25
|
+
// completely unchanged - safe to unwrap transparently when looking for the
|
|
26
|
+
// concrete leaf schema that actually carries checks.
|
|
27
|
+
const TRANSPARENT_WRAPPER_TYPES = new Set([
|
|
28
|
+
'optional', 'exactOptional', 'nullable', 'default', 'prefault', 'nonoptional', 'success', 'readonly', 'catch',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
module.exports = function(core) {
|
|
32
|
+
const { logger } = core;
|
|
33
|
+
const {
|
|
34
|
+
applyTags,
|
|
35
|
+
untrackValue,
|
|
36
|
+
retrackOverwrite,
|
|
37
|
+
tagCustomValidated,
|
|
38
|
+
handleTransformLike,
|
|
39
|
+
hasAnyTrackedValue,
|
|
40
|
+
getPropagatorContext,
|
|
41
|
+
} = require('./utils')(core);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Unwraps wrapper schema types (optional/nullable/default/.../lazy) to find
|
|
45
|
+
* the schema that actually carries checks. `.catch()` is included here: on
|
|
46
|
+
* the SUCCESS path (the only path this instrumentation ever sees, since the
|
|
47
|
+
* parse-family hooks only fire on success), a `.catch()` schema's result is
|
|
48
|
+
* either the inner schema's own validated value, or a fallback substituted
|
|
49
|
+
* by user code - in the fallback case `tracker.getData(result)` naturally
|
|
50
|
+
* finds nothing tracked (a fresh literal), so no special-casing is needed.
|
|
51
|
+
*/
|
|
52
|
+
function resolveConcreteSchema(schema) {
|
|
53
|
+
let current = schema;
|
|
54
|
+
while (current?._zod?.def) {
|
|
55
|
+
const { type, innerType } = current._zod.def;
|
|
56
|
+
if (TRANSPARENT_WRAPPER_TYPES.has(type)) {
|
|
57
|
+
current = innerType;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (type === 'lazy') {
|
|
61
|
+
current = current._zod.innerType;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
return current;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves the DataflowTag for a single check-shaped def (either a schema's
|
|
71
|
+
* own def, when the schema itself is a format/check node e.g. z.email(), or
|
|
72
|
+
* an entry in some other schema's def.checks array, e.g. z.string().email()
|
|
73
|
+
* - both shapes carry the same {check, format} discriminator). Deliberately
|
|
74
|
+
* excludes 'overwrite' and 'custom' checks - both matrices omit those keys,
|
|
75
|
+
* since they're handled by dedicated, gated code paths below rather than
|
|
76
|
+
* this blanket lookup.
|
|
77
|
+
*/
|
|
78
|
+
function tagForCheckDef(def) {
|
|
79
|
+
if (!def?.check) return null;
|
|
80
|
+
return def.check === 'string_format' ? FORMAT_MATRIX[def.format] : CHECK_TYPE_MATRIX[def.check];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Collects every DataflowTag that applies to a concrete (already-unwrapped)
|
|
85
|
+
* schema node. String-family schemas always get a baseline
|
|
86
|
+
* STRING_TYPE_CHECKED (mirroring joi's string.validate() tagging every
|
|
87
|
+
* successfully validated string, regardless of additional rules), on top
|
|
88
|
+
* of whichever more specific tags its own def or chained checks add.
|
|
89
|
+
*/
|
|
90
|
+
function checksToTags(def) {
|
|
91
|
+
const tags = new Set();
|
|
92
|
+
if (def.type === 'string') tags.add(STRING_TYPE_CHECKED);
|
|
93
|
+
|
|
94
|
+
const ownTag = tagForCheckDef(def);
|
|
95
|
+
if (ownTag) tags.add(ownTag);
|
|
96
|
+
|
|
97
|
+
for (const check of def.checks ?? []) {
|
|
98
|
+
const tag = tagForCheckDef(check._zod?.def);
|
|
99
|
+
if (tag) tags.add(tag);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return tags;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hasCheck(def, checkName) {
|
|
106
|
+
return (def.checks ?? []).some((check) => check._zod?.def?.check === checkName);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Recursively walks a schema alongside its pre-parse input and post-parse
|
|
111
|
+
* result, tagging/untracking/retracking tracked strings it finds. Returns
|
|
112
|
+
* the (possibly retracked) result value - the caller must use this return
|
|
113
|
+
* value, since retracking (trim/toLowerCase/transform) produces a NEW
|
|
114
|
+
* string object that the original caller of `.parse()` needs to actually
|
|
115
|
+
* receive in order for the tag to travel with it.
|
|
116
|
+
*/
|
|
117
|
+
function walk(schema, input, result, path) {
|
|
118
|
+
const concrete = resolveConcreteSchema(schema);
|
|
119
|
+
if (!concrete?._zod?.def) return result;
|
|
120
|
+
const { def } = concrete._zod;
|
|
121
|
+
|
|
122
|
+
if (UNTRACKING_SCHEMA_TYPES.has(def.type)) {
|
|
123
|
+
untrackValue(result);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (def.type === 'pipe') {
|
|
128
|
+
return handleTransformLike(def.in, input, result);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof result === 'string') {
|
|
132
|
+
let taggedResult = result;
|
|
133
|
+
if (hasCheck(def, 'overwrite')) {
|
|
134
|
+
taggedResult = retrackOverwrite(input, result, concrete);
|
|
135
|
+
}
|
|
136
|
+
applyTags(taggedResult, checksToTags(def), concrete, 'string');
|
|
137
|
+
if (hasCheck(def, 'custom')) {
|
|
138
|
+
taggedResult = tagCustomValidated({
|
|
139
|
+
sourceValue: input,
|
|
140
|
+
targetValue: taggedResult,
|
|
141
|
+
schema: concrete,
|
|
142
|
+
methodName: 'refine',
|
|
143
|
+
retrack: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return taggedResult;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
switch (def.type) {
|
|
150
|
+
case 'object':
|
|
151
|
+
if (isNonEmptyObject(input) && isNonEmptyObject(result)) {
|
|
152
|
+
for (const key of Object.keys(def.shape)) {
|
|
153
|
+
result[key] = walk(def.shape[key], input[key], result[key], [...path, key]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
case 'array':
|
|
158
|
+
if (Array.isArray(result)) {
|
|
159
|
+
result.forEach((value, i) => {
|
|
160
|
+
result[i] = walk(def.element, input?.[i], value, [...path, i]);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
default:
|
|
165
|
+
// unions/discriminatedUnion/xor/records/maps/sets/tuples/intersections
|
|
166
|
+
// are deliberately out of v1 scope - see plan's Open Questions.
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Entry point called from the parse-family post-hook. Bails out cheaply
|
|
173
|
+
* unless something in `input` or `result` is actually tracked, mirroring
|
|
174
|
+
* joi's early-return checks. Returns the (possibly retracked) result -
|
|
175
|
+
* callers must use the return value as the definitive result going
|
|
176
|
+
* forward.
|
|
177
|
+
*/
|
|
178
|
+
function handleParseSuccess(schema, input, result) {
|
|
179
|
+
try {
|
|
180
|
+
if (!getPropagatorContext()) return result;
|
|
181
|
+
if (!hasAnyTrackedValue(input) && !hasAnyTrackedValue(result)) return result;
|
|
182
|
+
|
|
183
|
+
return walk(schema, input, result, []);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
logger.error({ err }, 'zod parse propagation walk failed');
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { handleParseSuccess };
|
|
191
|
+
};
|