eslint-plugin-sfmc 4.1.2 → 4.2.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/README.md CHANGED
@@ -130,6 +130,8 @@ export default [...sfmc.configs['recommended-next'], ...sfmc.configs['embedded-n
130
130
  | [`sfmc/ssjs-no-mcn-unsupported`](docs/rules/ssjs/no-mcn-unsupported.md) | off (`error` in `-next`) | Flag all SSJS API usage as unsupported in Marketing Cloud Next |
131
131
  | [`sfmc/ssjs-no-deprecated-function`](docs/rules/ssjs/no-deprecated-function.md) | `error` | Flag use of deprecated SFMC SSJS APIs (e.g. ContentArea, ContentAreaObj) |
132
132
  | [`sfmc/ssjs-no-property-call`](docs/rules/ssjs/no-property-call.md) | `error` | Disallow calling Platform.Request/Response properties as functions |
133
+ | [`sfmc/ssjs-no-clr-header-access`](docs/rules/ssjs/no-clr-header-access.md) | `error` | Disallow CLR-unsafe reads of `HttpResponse.headers`; read via `for..in` |
134
+ | [`sfmc/ssjs-require-string-clr-content`](docs/rules/ssjs/require-string-clr-content.md) | `error` | Require wrapping `HttpResponse.content` with `String()` before use |
133
135
  | [`sfmc/ssjs-platform-function-arity`](docs/rules/ssjs/platform-function-arity.md) | `error` | Enforce correct arity for `Platform.Function.*` |
134
136
  | [`sfmc/ssjs-require-platform-load-order`](docs/rules/ssjs/require-platform-load-order.md) | `error` | Require `Platform.Load()` before Core usage in order |
135
137
  | [`sfmc/ssjs-no-hardcoded-credentials`](docs/rules/ssjs/no-hardcoded-credentials.md) | `error` | Flag hardcoded keys in encryption calls |
@@ -0,0 +1,91 @@
1
+ # `sfmc/ssjs-no-clr-header-access`
2
+
3
+ Flags CLR-unsafe reads of the `headers` property of an `HttpResponse` — the object
4
+ returned by `req.send()` on a `Script.Util.HttpRequest` or `Script.Util.HttpGet`.
5
+
6
+ `resp.headers` is a Common Language Runtime (CLR) object. Reading an individual
7
+ header by indexing it (`resp.headers["Content-Type"]`), or by calling `.Get()` /
8
+ `.Item()`, throws at runtime:
9
+
10
+ ```
11
+ Use of Common Language Runtime (CLR) is not allowed
12
+ ```
13
+
14
+ The only reliable way to read header values is to enumerate `resp.headers` with a
15
+ `for..in` loop. Each enumeration key is shaped `"[Name, Value]"`, so the value is
16
+ embedded in the key string. The `getHeaderMap()` helper parses those keys into a
17
+ plain `{ name: value }` map (with lowercased names).
18
+
19
+ ## Detection
20
+
21
+ The rule tracks data flow, so it only fires on a genuine response object:
22
+
23
+ 1. A **request** variable is one assigned from `new Script.Util.HttpRequest(...)`
24
+ or `Script.Util.HttpGet(...)`.
25
+ 2. A **response** variable is one assigned from `<requestVar>.send()`.
26
+ 3. CLR-style reads of `<responseVar>.headers` are then flagged.
27
+
28
+ `.headers` access on any other object is ignored, so there are no false positives
29
+ on unrelated `headers` properties.
30
+
31
+ ## Examples
32
+
33
+ ### ❌ Incorrect — CLR-style reads throw at runtime
34
+
35
+ ```js
36
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
37
+ var resp = req.send();
38
+
39
+ var ct = resp.headers["Content-Type"]; // ← throws (CLR not allowed)
40
+ var loc = resp.headers.Get("Location"); // ← throws (CLR not allowed)
41
+ var enc = resp.headers.Item("Content-Encoding"); // ← throws (CLR not allowed)
42
+ ```
43
+
44
+ ### ✅ Correct — read via a `getHeaderMap()` helper
45
+
46
+ ```js
47
+ /**
48
+ * Build a plain { name: value } header map from an HttpResponse.
49
+ * Reads only the for..in enumeration keys (shaped "[Name, Value]") so it never
50
+ * touches a CLR value — avoiding "Use of Common Language Runtime (CLR) is not allowed".
51
+ * @param {object} resp - the response returned by req.send()
52
+ * @returns {object} map of lowercased header name => value string
53
+ */
54
+ function getHeaderMap(resp) {
55
+ var map = {};
56
+ for (var k in resp.headers) {
57
+ var pair = String(k);
58
+ if (pair.charAt(0) === "[") { pair = pair.substring(1); }
59
+ if (pair.charAt(pair.length - 1) === "]") { pair = pair.substring(0, pair.length - 1); }
60
+ var idx = pair.indexOf(", ");
61
+ if (idx > -1) {
62
+ map[pair.substring(0, idx).toLowerCase()] = pair.substring(idx + 2);
63
+ }
64
+ }
65
+ return map;
66
+ }
67
+
68
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
69
+ var resp = req.send();
70
+
71
+ var headers = getHeaderMap(resp);
72
+ var ct = headers["content-type"]; // names are lowercased
73
+ ```
74
+
75
+ ## Suggestion (quick-fix)
76
+
77
+ The rule provides a suggestion that:
78
+
79
+ 1. Inserts the `getHeaderMap()` helper once, at the top of the file (skipped if a
80
+ `getHeaderMap(` function already exists).
81
+ 2. Rewrites the flagged access to `getHeaderMap(<resp>)[<key>]`.
82
+
83
+ The same diagnostic and quick-fix are available in the VS Code / Cursor extension
84
+ (`ssjs/clr-header-access`).
85
+
86
+ ## Rule details
87
+
88
+ - **Type:** `problem`
89
+ - **Fixable:** No (offers a suggestion)
90
+ - **Recommended:** Yes (`error`)
91
+ - **Strict:** Yes (`error`)
@@ -0,0 +1,69 @@
1
+ # `sfmc/ssjs-require-string-clr-content`
2
+
3
+ Requires wrapping the `content` property of an `HttpResponse` with `String()` before
4
+ use — the object returned by `req.send()` on a `Script.Util.HttpRequest` or
5
+ `Script.Util.HttpGet`.
6
+
7
+ `resp.content` is a Common Language Runtime (CLR) string, **not** a native JavaScript
8
+ string. Passing it directly to `Platform.Function.ParseJSON()`, calling a string
9
+ method on it, concatenating it, or assigning it to a variable used as a string is
10
+ unreliable in the SFMC SSJS (ES3/CLR) engine. The verified fix is to convert it with
11
+ `String(resp.content)` first:
12
+
13
+ ```js
14
+ var responseJSON = Platform.Function.ParseJSON(String(resp.content));
15
+ ```
16
+
17
+ ## Detection
18
+
19
+ The rule tracks data flow, so it only fires on a genuine response object:
20
+
21
+ 1. A **request** variable is one assigned from `new Script.Util.HttpRequest(...)`
22
+ or `Script.Util.HttpGet(...)`.
23
+ 2. A **response** variable is one assigned from `<requestVar>.send()`.
24
+ 3. Any read of `<responseVar>.content` that is **not** already the direct argument
25
+ of a `String(...)` call is flagged.
26
+
27
+ `.content` access on any other object is ignored, so there are no false positives on
28
+ unrelated `content` properties.
29
+
30
+ ## Examples
31
+
32
+ ### ❌ Incorrect — raw CLR content used directly
33
+
34
+ ```js
35
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
36
+ var resp = req.send();
37
+
38
+ var parsed = Platform.Function.ParseJSON(resp.content); // ← unreliable
39
+ var body = resp.content; // ← unreliable
40
+ var msg = "Body: " + resp.content; // ← unreliable
41
+ var head = resp.content.substring(0, 20); // ← unreliable
42
+ ```
43
+
44
+ ### ✅ Correct — wrap with `String()` first
45
+
46
+ ```js
47
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
48
+ var resp = req.send();
49
+
50
+ var parsed = Platform.Function.ParseJSON(String(resp.content));
51
+ var body = String(resp.content);
52
+ var head = String(resp.content).substring(0, 20);
53
+ ```
54
+
55
+ ## Fix (auto-fix)
56
+
57
+ The rule is auto-fixable: it wraps the flagged `<resp>.content` access in
58
+ `String(...)`. For example `ParseJSON(resp.content)` becomes
59
+ `ParseJSON(String(resp.content))`.
60
+
61
+ The same diagnostic and quick-fix are available in the VS Code / Cursor extension
62
+ (`ssjs/clr-content-access`).
63
+
64
+ ## Rule details
65
+
66
+ - **Type:** `problem`
67
+ - **Fixable:** Yes (`code`)
68
+ - **Recommended:** Yes (`error`)
69
+ - **Strict:** Yes (`error`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-sfmc",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "description": "ESLint plugin for Salesforce Marketing Cloud Engagement+Next - AMPscript, Server-Side JavaScript (SSJS) and Handlebars",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -42,7 +42,7 @@
42
42
  "eslint-plugin-mso-email": "^2.0.0",
43
43
  "handlebars-data": "^0.3.0",
44
44
  "mso-conditional-parser": "^2.0.1",
45
- "ssjs-data": "^0.14.0"
45
+ "ssjs-data": "^0.16.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "eslint": ">=9.0.0"
package/src/index.js CHANGED
@@ -56,6 +56,8 @@ import ssjsNoSwitchDefault from './rules/ssjs/no-switch-default.js';
56
56
  import ssjsNoTreatAsContentInjection from './rules/ssjs/no-treatascontent-injection.js';
57
57
  import ssjsArgumentTypes from './rules/ssjs/ssjs-argument-types.js';
58
58
  import ssjsCoreMethodArity from './rules/ssjs/ssjs-core-method-arity.js';
59
+ import ssjsNoClrHeaderAccess from './rules/ssjs/no-clr-header-access.js';
60
+ import ssjsRequireStringClrContent from './rules/ssjs/require-string-clr-content.js';
59
61
 
60
62
  // ── Handlebars (MCN) rules ──────────────────────────────────────────────────────
61
63
 
@@ -135,6 +137,8 @@ const plugin = {
135
137
  'ssjs-no-treatascontent-injection': ssjsNoTreatAsContentInjection,
136
138
  'ssjs-arg-types': ssjsArgumentTypes,
137
139
  'ssjs-core-method-arity': ssjsCoreMethodArity,
140
+ 'ssjs-no-clr-header-access': ssjsNoClrHeaderAccess,
141
+ 'ssjs-require-string-clr-content': ssjsRequireStringClrContent,
138
142
 
139
143
  // Handlebars (MCN) rules (hbs- prefix)
140
144
  'hbs-no-unknown-helper': hbsNoUnknownHelper,
@@ -178,6 +182,8 @@ const ssjsMcnRules = {
178
182
  'sfmc/ssjs-no-treatascontent-injection': 'off',
179
183
  'sfmc/ssjs-arg-types': 'off',
180
184
  'sfmc/ssjs-core-method-arity': 'off',
185
+ 'sfmc/ssjs-no-clr-header-access': 'off',
186
+ 'sfmc/ssjs-require-string-clr-content': 'off',
181
187
  'no-cond-assign': 'off',
182
188
  };
183
189
 
@@ -246,6 +252,8 @@ const ssjsRecommendedRules = {
246
252
  'sfmc/ssjs-no-treatascontent-injection': 'warn',
247
253
  'sfmc/ssjs-arg-types': 'warn',
248
254
  'sfmc/ssjs-core-method-arity': 'warn',
255
+ 'sfmc/ssjs-no-clr-header-access': 'error',
256
+ 'sfmc/ssjs-require-string-clr-content': 'error',
249
257
  'no-cond-assign': 'error',
250
258
  };
251
259
 
@@ -267,6 +275,8 @@ const ssjsStrictRules = {
267
275
  'sfmc/ssjs-no-treatascontent-injection': 'error',
268
276
  'sfmc/ssjs-arg-types': 'warn',
269
277
  'sfmc/ssjs-core-method-arity': 'error',
278
+ 'sfmc/ssjs-no-clr-header-access': 'error',
279
+ 'sfmc/ssjs-require-string-clr-content': 'error',
270
280
  'no-cond-assign': 'error',
271
281
  };
272
282
 
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Rule: ssjs-no-clr-header-access
3
+ *
4
+ * The `headers` property of an HttpResponse (returned by `req.send()` on a
5
+ * `Script.Util.HttpRequest` or `Script.Util.HttpGet`) is a CLR object. Reading
6
+ * an individual header via indexing (`resp.headers["Content-Type"]`), a `.Get()`
7
+ * / `.Item()` call, or `String(resp.headers[key])` throws at runtime:
8
+ *
9
+ * "Use of Common Language Runtime (CLR) is not allowed"
10
+ *
11
+ * The only reliable way to read header values is to enumerate `resp.headers`
12
+ * with a `for..in` loop — each key is shaped `"[Name, Value]"`, so the value is
13
+ * embedded in the key string. The `getHeaderMap()` helper parses those keys into
14
+ * a plain `{ name: value }` map.
15
+ *
16
+ * This rule tracks variables assigned from `req.send()` (where `req` came from a
17
+ * `Script.Util.HttpRequest`/`HttpGet`) and flags CLR-style reads of their
18
+ * `.headers`. It offers a suggestion that inserts the `getHeaderMap()` helper
19
+ * and rewrites the access to `getHeaderMap(resp)["name"]`.
20
+ */
21
+
22
+ /** Canonical helper inserted by the quick-fix, mirrored from ssjs.guide. */
23
+ const HEADER_MAP_HELPER = `/**
24
+ * Build a plain { name: value } header map from an HttpResponse.
25
+ * Reads only the for..in enumeration keys (shaped "[Name, Value]") so it never
26
+ * touches a CLR value — avoiding "Use of Common Language Runtime (CLR) is not allowed".
27
+ * @param {object} resp - the response returned by req.send()
28
+ * @returns {object} map of lowercased header name => value string
29
+ */
30
+ function getHeaderMap(resp) {
31
+ var map = {};
32
+ for (var k in resp.headers) {
33
+ var pair = String(k);
34
+ if (pair.charAt(0) === "[") { pair = pair.substring(1); }
35
+ if (pair.charAt(pair.length - 1) === "]") { pair = pair.substring(0, pair.length - 1); }
36
+ var idx = pair.indexOf(", ");
37
+ if (idx > -1) {
38
+ map[pair.substring(0, idx).toLowerCase()] = pair.substring(idx + 2);
39
+ }
40
+ }
41
+ return map;
42
+ }
43
+ `;
44
+
45
+ /**
46
+ * Returns true when the node is `Script.Util.HttpRequest` or
47
+ * `Script.Util.HttpGet` (a MemberExpression, optionally the callee of `new`).
48
+ *
49
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
50
+ * @returns {boolean} Whether it references a Script.Util HTTP constructor
51
+ */
52
+ function isHttpConstructorMember(node) {
53
+ return (
54
+ node.type === 'MemberExpression' &&
55
+ node.property.type === 'Identifier' &&
56
+ (node.property.name === 'HttpRequest' || node.property.name === 'HttpGet') &&
57
+ node.object.type === 'MemberExpression' &&
58
+ node.object.property.type === 'Identifier' &&
59
+ node.object.property.name === 'Util' &&
60
+ node.object.object.type === 'Identifier' &&
61
+ node.object.object.name === 'Script'
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Returns true when the node constructs a Script.Util HTTP request, i.e.
67
+ * `new Script.Util.HttpRequest(...)` or `Script.Util.HttpGet(...)` (the latter
68
+ * is commonly called without `new`).
69
+ *
70
+ * @param {import('eslint').Rule.Node} node - init expression of a declarator
71
+ * @returns {boolean} Whether the expression yields an HTTP request instance
72
+ */
73
+ function isHttpRequestInit(node) {
74
+ if (!node) {
75
+ return false;
76
+ }
77
+ if (node.type === 'NewExpression') {
78
+ return isHttpConstructorMember(node.callee);
79
+ }
80
+ if (node.type === 'CallExpression') {
81
+ return isHttpConstructorMember(node.callee);
82
+ }
83
+ return false;
84
+ }
85
+
86
+ export default {
87
+ meta: {
88
+ type: 'problem',
89
+ hasSuggestions: true,
90
+ docs: {
91
+ description:
92
+ 'Disallow CLR-unsafe reads of HttpResponse.headers (indexing, .Get(), .Item(), String()) — read headers via a for..in map instead',
93
+ },
94
+ messages: {
95
+ clrHeaderAccess:
96
+ 'Reading `{{ text }}` throws "Use of Common Language Runtime (CLR) is not allowed" at runtime. ' +
97
+ 'HttpResponse headers are only readable by enumerating with for..in — use a getHeaderMap() helper.',
98
+ insertHelperAndRewrite:
99
+ 'Insert getHeaderMap() helper and read via getHeaderMap({{ respName }})[…]',
100
+ },
101
+ schema: [],
102
+ },
103
+
104
+ create(context) {
105
+ const sourceCode = context.sourceCode;
106
+
107
+ // Variable names that hold an HttpRequest/HttpGet instance.
108
+ const requestVariables = new Set();
109
+ // Variable names that hold the response of `<requestVar>.send()`.
110
+ const responseVariables = new Set();
111
+ // Deferred reports so name collection completes before we decide.
112
+ const pending = [];
113
+
114
+ /**
115
+ * Returns true when `node` is a `<requestVar>.send()` call.
116
+ *
117
+ * @param {import('eslint').Rule.Node} node - init expression
118
+ * @returns {boolean} Whether it is a tracked request's send() call
119
+ */
120
+ function isTrackedSendCall(node) {
121
+ return Boolean(
122
+ node &&
123
+ node.type === 'CallExpression' &&
124
+ node.callee.type === 'MemberExpression' &&
125
+ node.callee.property.type === 'Identifier' &&
126
+ node.callee.property.name === 'send' &&
127
+ node.callee.object.type === 'Identifier' &&
128
+ requestVariables.has(node.callee.object.name),
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Returns the response variable name for a `<obj>.headers` member
134
+ * expression when `<obj>` is a tracked response variable, else null.
135
+ *
136
+ * @param {import('eslint').Rule.Node} headersMember - the `.headers` MemberExpression
137
+ * @returns {string|null} Response variable name or null
138
+ */
139
+ function trackedResponseName(headersMember) {
140
+ if (
141
+ headersMember.type === 'MemberExpression' &&
142
+ headersMember.property.type === 'Identifier' &&
143
+ headersMember.property.name === 'headers' &&
144
+ headersMember.object.type === 'Identifier' &&
145
+ responseVariables.has(headersMember.object.name)
146
+ ) {
147
+ return headersMember.object.name;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Builds the suggestion descriptor: insert the helper at the top of the
154
+ * program (once) and rewrite the flagged access to use getHeaderMap().
155
+ *
156
+ * @param {import('eslint').Rule.Node} reportNode - node covered by the diagnostic
157
+ * @param {import('eslint').Rule.Node} keyNode - the header-key expression
158
+ * @param {string} respName - tracked response variable name
159
+ * @returns {object} ESLint suggestion object
160
+ */
161
+ function buildSuggestion(reportNode, keyNode, respName) {
162
+ const keyText = keyNode ? sourceCode.getText(keyNode) : '';
163
+ return {
164
+ messageId: 'insertHelperAndRewrite',
165
+ data: { respName },
166
+ fix(fixer) {
167
+ const fixes = [
168
+ fixer.replaceText(reportNode, `getHeaderMap(${respName})[${keyText}]`),
169
+ ];
170
+ // Insert the helper once, at the very top of the program.
171
+ if (!sourceCode.getText().includes('function getHeaderMap(')) {
172
+ const program = sourceCode.ast;
173
+ fixes.push(fixer.insertTextBefore(program, `${HEADER_MAP_HELPER}\n`));
174
+ }
175
+ return fixes;
176
+ },
177
+ };
178
+ }
179
+
180
+ return {
181
+ VariableDeclarator(node) {
182
+ if (node.id.type !== 'Identifier') {
183
+ return;
184
+ }
185
+ if (isHttpRequestInit(node.init)) {
186
+ requestVariables.add(node.id.name);
187
+ } else if (isTrackedSendCall(node.init)) {
188
+ responseVariables.add(node.id.name);
189
+ }
190
+ },
191
+
192
+ // resp.headers["Content-Type"] — computed index read.
193
+ 'MemberExpression[computed=true]'(node) {
194
+ const respName = trackedResponseName(node.object);
195
+ if (!respName) {
196
+ return;
197
+ }
198
+ pending.push({
199
+ reportNode: node,
200
+ keyNode: node.property,
201
+ respName,
202
+ });
203
+ },
204
+
205
+ // resp.headers.Get(...) / resp.headers.Item(...) — CLR method call.
206
+ 'CallExpression > MemberExpression.callee'(node) {
207
+ if (
208
+ node.property.type !== 'Identifier' ||
209
+ (node.property.name !== 'Get' && node.property.name !== 'Item')
210
+ ) {
211
+ return;
212
+ }
213
+ const respName = trackedResponseName(node.object);
214
+ if (!respName) {
215
+ return;
216
+ }
217
+ const call = node.parent;
218
+ pending.push({
219
+ reportNode: call,
220
+ keyNode: call.arguments.length > 0 ? call.arguments[0] : null,
221
+ respName,
222
+ });
223
+ },
224
+
225
+ 'Program:exit'() {
226
+ for (const { reportNode, keyNode, respName } of pending) {
227
+ context.report({
228
+ node: reportNode,
229
+ messageId: 'clrHeaderAccess',
230
+ data: { text: sourceCode.getText(reportNode) },
231
+ suggest: [buildSuggestion(reportNode, keyNode, respName)],
232
+ });
233
+ }
234
+ },
235
+ };
236
+ },
237
+ };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Rule: ssjs-require-string-clr-content
3
+ *
4
+ * The `content` property of an HttpResponse (returned by `req.send()` on a
5
+ * `Script.Util.HttpRequest` or `Script.Util.HttpGet`) is a CLR string object,
6
+ * not a native JavaScript string. Using it directly — passing it to
7
+ * `Platform.Function.ParseJSON()`, calling a string method on it, concatenating
8
+ * it, or assigning it to a variable used as a string — is unreliable in the
9
+ * SFMC SSJS (ES3/CLR) engine.
10
+ *
11
+ * The verified fix is to wrap the value with `String(resp.content)` before any
12
+ * further use, e.g. `Platform.Function.ParseJSON(String(resp.content))`.
13
+ *
14
+ * This rule tracks variables assigned from `req.send()` (where `req` came from a
15
+ * `Script.Util.HttpRequest`/`HttpGet`) and flags any read of their `.content`
16
+ * that is not already the direct argument of a `String(...)` call. The quick-fix
17
+ * wraps the access in `String(...)`.
18
+ */
19
+
20
+ /**
21
+ * Returns true when the node is `Script.Util.HttpRequest` or
22
+ * `Script.Util.HttpGet` (a MemberExpression, optionally the callee of `new`).
23
+ *
24
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
25
+ * @returns {boolean} Whether it references a Script.Util HTTP constructor
26
+ */
27
+ function isHttpConstructorMember(node) {
28
+ return (
29
+ node.type === 'MemberExpression' &&
30
+ node.property.type === 'Identifier' &&
31
+ (node.property.name === 'HttpRequest' || node.property.name === 'HttpGet') &&
32
+ node.object.type === 'MemberExpression' &&
33
+ node.object.property.type === 'Identifier' &&
34
+ node.object.property.name === 'Util' &&
35
+ node.object.object.type === 'Identifier' &&
36
+ node.object.object.name === 'Script'
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Returns true when the node constructs a Script.Util HTTP request, i.e.
42
+ * `new Script.Util.HttpRequest(...)` or `Script.Util.HttpGet(...)` (the latter
43
+ * is commonly called without `new`).
44
+ *
45
+ * @param {import('eslint').Rule.Node} node - init expression of a declarator
46
+ * @returns {boolean} Whether the expression yields an HTTP request instance
47
+ */
48
+ function isHttpRequestInit(node) {
49
+ if (!node) {
50
+ return false;
51
+ }
52
+ if (node.type === 'NewExpression') {
53
+ return isHttpConstructorMember(node.callee);
54
+ }
55
+ if (node.type === 'CallExpression') {
56
+ return isHttpConstructorMember(node.callee);
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Returns true when the `.content` member is already the direct single argument
63
+ * of a `String(...)` call — the verified-safe pattern.
64
+ *
65
+ * @param {import('eslint').Rule.Node} node - the `.content` MemberExpression
66
+ * @returns {boolean} Whether it is wrapped by String(...)
67
+ */
68
+ function isWrappedInString(node) {
69
+ const parent = node.parent;
70
+ return Boolean(
71
+ parent &&
72
+ parent.type === 'CallExpression' &&
73
+ parent.callee.type === 'Identifier' &&
74
+ parent.callee.name === 'String' &&
75
+ parent.arguments.length === 1 &&
76
+ parent.arguments[0] === node,
77
+ );
78
+ }
79
+
80
+ export default {
81
+ meta: {
82
+ type: 'problem',
83
+ fixable: 'code',
84
+ docs: {
85
+ description:
86
+ 'Require wrapping HttpResponse.content with String() before use — the raw CLR string is unreliable in the SSJS engine',
87
+ },
88
+ messages: {
89
+ clrContentAccess:
90
+ 'Reading `{{ text }}` directly is unreliable — `content` is a CLR string, not a JavaScript string. ' +
91
+ 'Wrap it with `String({{ text }})` before passing it to ParseJSON() or any string operation.',
92
+ },
93
+ schema: [],
94
+ },
95
+
96
+ create(context) {
97
+ const sourceCode = context.sourceCode;
98
+
99
+ // Variable names that hold an HttpRequest/HttpGet instance.
100
+ const requestVariables = new Set();
101
+ // Variable names that hold the response of `<requestVar>.send()`.
102
+ const responseVariables = new Set();
103
+ // Deferred reports so name collection completes before we decide.
104
+ const pending = [];
105
+
106
+ /**
107
+ * Returns true when `node` is a `<requestVar>.send()` call.
108
+ *
109
+ * @param {import('eslint').Rule.Node} node - init expression
110
+ * @returns {boolean} Whether it is a tracked request's send() call
111
+ */
112
+ function isTrackedSendCall(node) {
113
+ return Boolean(
114
+ node &&
115
+ node.type === 'CallExpression' &&
116
+ node.callee.type === 'MemberExpression' &&
117
+ node.callee.property.type === 'Identifier' &&
118
+ node.callee.property.name === 'send' &&
119
+ node.callee.object.type === 'Identifier' &&
120
+ requestVariables.has(node.callee.object.name),
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Returns true when `node` is a `<responseVar>.content` member read on a
126
+ * tracked response variable.
127
+ *
128
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
129
+ * @returns {boolean} Whether it reads .content on a tracked response var
130
+ */
131
+ function isTrackedContentMember(node) {
132
+ return (
133
+ node.type === 'MemberExpression' &&
134
+ !node.computed &&
135
+ node.property.type === 'Identifier' &&
136
+ node.property.name === 'content' &&
137
+ node.object.type === 'Identifier' &&
138
+ responseVariables.has(node.object.name)
139
+ );
140
+ }
141
+
142
+ return {
143
+ VariableDeclarator(node) {
144
+ if (node.id.type !== 'Identifier') {
145
+ return;
146
+ }
147
+ if (isHttpRequestInit(node.init)) {
148
+ requestVariables.add(node.id.name);
149
+ } else if (isTrackedSendCall(node.init)) {
150
+ responseVariables.add(node.id.name);
151
+ }
152
+ },
153
+
154
+ // resp.content — non-computed member read on a tracked response var.
155
+ 'MemberExpression[computed=false]'(node) {
156
+ if (!isTrackedContentMember(node)) {
157
+ return;
158
+ }
159
+ if (isWrappedInString(node)) {
160
+ return;
161
+ }
162
+ pending.push(node);
163
+ },
164
+
165
+ 'Program:exit'() {
166
+ for (const node of pending) {
167
+ const text = sourceCode.getText(node);
168
+ context.report({
169
+ node,
170
+ messageId: 'clrContentAccess',
171
+ data: { text },
172
+ fix(fixer) {
173
+ return fixer.replaceText(node, `String(${text})`);
174
+ },
175
+ });
176
+ }
177
+ },
178
+ };
179
+ },
180
+ };