eslint-plugin-sfmc 4.1.1 → 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 +3 -0
- package/docs/rules/ssjs/no-clr-header-access.md +91 -0
- package/docs/rules/ssjs/require-string-clr-content.md +69 -0
- package/docs/unicorn-compatibility.md +1 -0
- package/package.json +2 -2
- package/src/index.js +10 -0
- package/src/rules/ssjs/no-clr-header-access.js +237 -0
- package/src/rules/ssjs/require-string-clr-content.js +180 -0
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 |
|
|
@@ -189,6 +191,7 @@ import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
|
|
189
191
|
export default [
|
|
190
192
|
eslintPluginUnicorn.configs.recommended, // you opt in — registers the `unicorn` plugin
|
|
191
193
|
...sfmc.configs.recommended,
|
|
194
|
+
...sfmc.configs.embedded, // AMPscript + SSJS embedded in HTML (<script runat="server">)
|
|
192
195
|
...sfmc.configs['unicorn-ssjs'], // OPTIONAL: off the 46 SFMC-incompatible unicorn rules for SSJS
|
|
193
196
|
...sfmc.configs['unicorn-ssjs-embedded'], // OPTIONAL: same override for SSJS embedded in HTML (<script runat="server">)
|
|
194
197
|
];
|
|
@@ -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`)
|
|
@@ -24,6 +24,7 @@ import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
|
|
24
24
|
export default [
|
|
25
25
|
eslintPluginUnicorn.configs.recommended, // you opt in — registers the `unicorn` plugin
|
|
26
26
|
...sfmc.configs.recommended,
|
|
27
|
+
...sfmc.configs.embedded, // AMPscript + SSJS embedded in HTML (<script runat="server">)
|
|
27
28
|
...sfmc.configs['unicorn-ssjs'], // OPTIONAL: off the 46 SFMC-incompatible unicorn rules for SSJS
|
|
28
29
|
...sfmc.configs['unicorn-ssjs-embedded'], // OPTIONAL: same override for SSJS embedded in HTML (<script runat="server">)
|
|
29
30
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-sfmc",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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
|
+
};
|