eslint-plugin-chrome-extensions 1.0.1 → 2.0.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 +217 -66
- package/package.json +5 -4
- package/src/data/manifest.js +24 -5
- package/src/index.js +19 -22
- package/src/rules/manifest-key-order.js +75 -0
- package/src/rules/no-content-style-leak.js +1 -1
- package/src/rules/no-invalid-permissions.js +26 -5
- package/src/rules/no-remote-code.js +5 -5
- package/src/rules/no-unused-permissions.js +64 -19
- package/src/rules/require-mv3.js +20 -2
- package/src/rules/valid-mv3-schema.js +52 -4
- package/src/rules/valid-version-field.js +95 -0
- package/src/utils.js +10 -3
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<a href="#license">
|
|
8
8
|
<img alt="[License: MIT]" src="https://img.shields.io/badge/License-MIT-f1f20a.svg?logo=internetarchive&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
9
9
|
<a target="_blank" href="https://eslint.org/">
|
|
10
|
-
<img alt="[Requires ESLint >=9.
|
|
10
|
+
<img alt="[Requires ESLint >=9.6.0]" src="https://img.shields.io/badge/Requires-ESLint_%3E%3D9.6.0-4B32C3?style=for-the-badge&logo=eslint"></a>
|
|
11
11
|
<br><br>
|
|
12
12
|
|
|
13
13
|
ESLint rules for Chrome extensions.
|
|
@@ -31,26 +31,37 @@ npm i -D eslint-plugin-chrome-extensions
|
|
|
31
31
|
Usage
|
|
32
32
|
</h2>
|
|
33
33
|
|
|
34
|
-
Add
|
|
34
|
+
Add bundled configs to your `eslint.config.*js`:
|
|
35
35
|
|
|
36
36
|
```js
|
|
37
37
|
...
|
|
38
38
|
import chromeExtensions from 'eslint-plugin-chrome-extensions'
|
|
39
|
+
import json from '@eslint/json'
|
|
39
40
|
|
|
40
41
|
export default [
|
|
41
42
|
// other configs
|
|
42
43
|
{
|
|
43
|
-
files: ['
|
|
44
|
+
files: ['extension/**/*.js'],
|
|
45
|
+
plugins: { 'chrome-extensions': chromeExtensions },
|
|
44
46
|
rules: {
|
|
45
47
|
...chromeExtensions.configs.recommended.rules,
|
|
46
|
-
'chrome-extensions/no-
|
|
48
|
+
'chrome-extensions/no-insecure-messaging': 'off', // selectively disable rule
|
|
47
49
|
'chrome-extensions/no-deprecated-apis': ['error', { target: 'mv3' }] // selectively tweak rule
|
|
48
50
|
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
files: ['extension/manifest.json'], language: 'json/json',
|
|
54
|
+
plugins: { 'chrome-extensions': chromeExtensions, json },
|
|
55
|
+
rules: {
|
|
56
|
+
...chromeExtensions.configs.manifest.rules,
|
|
57
|
+
'chrome-extensions/require-mv3': 'off', // selectively disable rule
|
|
58
|
+
'chrome-extensions/valid-version-field': ['error', { versionType: 'date' }] // selectively tweak rule
|
|
59
|
+
}
|
|
49
60
|
}
|
|
50
61
|
]
|
|
51
62
|
```
|
|
52
63
|
|
|
53
|
-
Available bundles: `recommended`, `strict` (everything), `problem` (deterministic bugs only)
|
|
64
|
+
Available bundles: `recommended`, `strict` (everything), `problem` (deterministic bugs only), `manifest`
|
|
54
65
|
|
|
55
66
|
**Note:** Rule config is either a severity string (`'off'`, `'warn'`, `'error'`) or an array `[severity, options]` when the rule accepts options.
|
|
56
67
|
|
|
@@ -61,6 +72,75 @@ Available bundles: `recommended`, `strict` (everything), `problem` (deterministi
|
|
|
61
72
|
Supported rules
|
|
62
73
|
</h2>
|
|
63
74
|
|
|
75
|
+
<h3 id="manifest-key-order">
|
|
76
|
+
<code>manifest-key-order</code>
|
|
77
|
+
<a title="Accepts eslint --fix" target="_blank" href="https://eslint.org/docs/latest/use/command-line-interface#--fix">
|
|
78
|
+
<img height=18 alt="[Auto-fixable]" src="https://img.shields.io/badge/Auto--fixable-3b5bdb?style=flat-square" align="middle"></a>
|
|
79
|
+
<a title="Doesn't prevent bugs" href="#manifest-key-order">
|
|
80
|
+
<img height=18 alt="[Stylistic]" src="https://img.shields.io/badge/Stylistic-af68ff?style=flat-square" align="middle"></a>
|
|
81
|
+
<a title="Accepts array w/ options" href="#usage">
|
|
82
|
+
<img height=18 alt="[Configurable]" src="https://img.shields.io/badge/Configurable-black?style=flat-square" align="middle"></a>
|
|
83
|
+
<a title="Copy rule URL" target="_blank"
|
|
84
|
+
href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23manifest-key-order">
|
|
85
|
+
<img height=16 width=21 alt="[Copy rule URL]" src="https://api.iconify.design/mdi/content-copy.svg?color=%23cccccc" align="middle"></a>
|
|
86
|
+
</h3>
|
|
87
|
+
|
|
88
|
+
<details>
|
|
89
|
+
|
|
90
|
+
<summary>Enforces a preferred order for manifest keys.</summary>
|
|
91
|
+
|
|
92
|
+
#### Options
|
|
93
|
+
|
|
94
|
+
- `order` (default `[]`) – manifest keys in custom order (e.g. `['name', 'manifest_version']`) to selectively override built-in order with
|
|
95
|
+
|
|
96
|
+
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
|
|
97
|
+
|
|
98
|
+
```diff
|
|
99
|
+
/* eslint chrome-extensions/manifest-key-order: ['error', { order: ['name', 'manifest_version'] }] */
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
"manifest_version": 3,
|
|
103
|
+
! "name": "My Extension",
|
|
104
|
+
"version": "1.0.0"
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
109
|
+
|
|
110
|
+
```diff
|
|
111
|
+
/* eslint chrome-extensions/manifest-key-order: ['error', { order: ['name', 'manifest_version'] }] */
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
- "manifest_version": 3,
|
|
115
|
+
"name": "My Extension",
|
|
116
|
+
+ "manifest_version": 3,
|
|
117
|
+
"version": "1.0.0"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### When not to use it
|
|
122
|
+
|
|
123
|
+
If you don't care about the order of manifest keys.
|
|
124
|
+
|
|
125
|
+
#### Introduced in
|
|
126
|
+
|
|
127
|
+
<a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/releases/tag/v2.0.0">v2.0.0</a> (October 4, 2026)
|
|
128
|
+
|
|
129
|
+
#### See also
|
|
130
|
+
|
|
131
|
+
- [`require-mv3`](#require-mv3)
|
|
132
|
+
- [`valid-mv3-schema`](#valid-mv3-schema)
|
|
133
|
+
|
|
134
|
+
<div align="center">
|
|
135
|
+
|
|
136
|
+
[Back to index ↑][epc-rules]
|
|
137
|
+
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
</details>
|
|
143
|
+
|
|
64
144
|
<h3 id="no-content-style-leak">
|
|
65
145
|
<code>no-content-style-leak</code>
|
|
66
146
|
<a title="Only included in strict config" href="#usage">
|
|
@@ -83,13 +163,16 @@ Available bundles: `recommended`, `strict` (everything), `problem` (deterministi
|
|
|
83
163
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
84
164
|
|
|
85
165
|
```diff
|
|
166
|
+
+ const host = document.createElement('div'),
|
|
167
|
+
+ root = host.attachShadow({ mode: 'open' })
|
|
168
|
+
+ document.body.append(host)
|
|
86
169
|
- document.head.append(document.createElement('style'))
|
|
87
|
-
+
|
|
170
|
+
+ root.append(document.createElement('style'))
|
|
88
171
|
```
|
|
89
172
|
|
|
90
173
|
#### When not to use it
|
|
91
174
|
|
|
92
|
-
If you
|
|
175
|
+
If you intentionally interact w/ host page styles.
|
|
93
176
|
|
|
94
177
|
#### Introduced in
|
|
95
178
|
|
|
@@ -97,9 +180,8 @@ If you already scope every injected style inside a `shadowRoot` you own.
|
|
|
97
180
|
|
|
98
181
|
#### See also
|
|
99
182
|
|
|
100
|
-
-
|
|
101
|
-
- <a target="_blank" href="https://developer.
|
|
102
|
-
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts">Chrome Extensions — Content scripts</a>
|
|
183
|
+
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts">Chrome for Developers — Content scripts</a>
|
|
184
|
+
- <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM">MDN — Using shadow DOM</a>
|
|
103
185
|
|
|
104
186
|
<div align="center">
|
|
105
187
|
|
|
@@ -138,6 +220,7 @@ If you already scope every injected style inside a `shadowRoot` you own.
|
|
|
138
220
|
|
|
139
221
|
! chrome.pageAction.show(tabId)
|
|
140
222
|
! chrome.extension.getBackgroundPage().doThing()
|
|
223
|
+
! chrome.tabs.executeScript(tabId, { code: 'doThing()' })
|
|
141
224
|
```
|
|
142
225
|
|
|
143
226
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
@@ -149,6 +232,8 @@ If you already scope every injected style inside a `shadowRoot` you own.
|
|
|
149
232
|
+ chrome.action.enable(tabId)
|
|
150
233
|
- chrome.extension.getBackgroundPage().doThing()
|
|
151
234
|
+ await chrome.runtime.getContexts({ contextTypes: ['BACKGROUND'] })
|
|
235
|
+
- chrome.tabs.executeScript(tabId, { code: 'doThing()' })
|
|
236
|
+
+ chrome.scripting.executeScript({ target: { tabId }, func: doThing })
|
|
152
237
|
```
|
|
153
238
|
|
|
154
239
|
#### When not to use it
|
|
@@ -161,9 +246,7 @@ If you don't want deprecation validation at all. For individual deprecated APIs
|
|
|
161
246
|
|
|
162
247
|
#### See also
|
|
163
248
|
|
|
164
|
-
-
|
|
165
|
-
- [`valid-mv3-schema`](#valid-mv3-schema)
|
|
166
|
-
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api">Chrome Extensions — API reference</a>
|
|
249
|
+
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api">Chrome for Developers — API reference</a>
|
|
167
250
|
|
|
168
251
|
<div align="center">
|
|
169
252
|
|
|
@@ -191,26 +274,27 @@ If you don't want deprecation validation at all. For individual deprecated APIs
|
|
|
191
274
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
|
|
192
275
|
|
|
193
276
|
```diff
|
|
194
|
-
|
|
195
|
-
! handle(msg)
|
|
277
|
+
chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
|
|
278
|
+
! handle(msg)
|
|
279
|
+
})
|
|
196
280
|
|
|
197
|
-
! chrome.tabs.query({})
|
|
198
|
-
|
|
281
|
+
! chrome.tabs.query({})
|
|
282
|
+
.then(tabs => tabs.forEach(tab =>
|
|
283
|
+
chrome.tabs.sendMessage(tab.id, { action: 'ping' })))
|
|
199
284
|
```
|
|
200
285
|
|
|
201
286
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
202
287
|
|
|
203
288
|
```diff
|
|
204
|
-
|
|
205
|
-
- handle(msg) })
|
|
206
|
-
+ chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
|
|
289
|
+
chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
|
|
207
290
|
+ if (sender.id != 'allowed-extension-id') return
|
|
208
|
-
|
|
209
|
-
|
|
291
|
+
handle(msg)
|
|
292
|
+
})
|
|
210
293
|
|
|
211
|
-
- chrome.tabs.query({})
|
|
212
|
-
+ chrome.tabs.query({ url: 'https://
|
|
213
|
-
|
|
294
|
+
- chrome.tabs.query({})
|
|
295
|
+
+ chrome.tabs.query({ url: 'https://allowed-origin.com/*' })
|
|
296
|
+
.then(tabs => tabs.forEach(tab =>
|
|
297
|
+
chrome.tabs.sendMessage(tab.id, { action: 'ping' })))
|
|
214
298
|
```
|
|
215
299
|
|
|
216
300
|
#### When not to use it
|
|
@@ -223,8 +307,8 @@ If you trust every external caller and open tab.
|
|
|
223
307
|
|
|
224
308
|
#### See also
|
|
225
309
|
|
|
226
|
-
-
|
|
227
|
-
-
|
|
310
|
+
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api/runtime#event-onMessageExternal">Chrome for Developers — `runtime.onMessageExternal`</a>
|
|
311
|
+
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api/tabs#method-query">Chrome for Developers — `tabs.query()`</a>
|
|
228
312
|
|
|
229
313
|
<div align="center">
|
|
230
314
|
|
|
@@ -262,8 +346,8 @@ If you trust every external caller and open tab.
|
|
|
262
346
|
/* eslint chrome-extensions/no-invalid-permissions: ['error', { target: 'mv3' }] */
|
|
263
347
|
|
|
264
348
|
{
|
|
265
|
-
!
|
|
266
|
-
!
|
|
349
|
+
! "permissions": ["browserAction", "tabs"],
|
|
350
|
+
! "optional_permissions": ["history", "pageAction"]
|
|
267
351
|
}
|
|
268
352
|
```
|
|
269
353
|
|
|
@@ -273,10 +357,10 @@ If you trust every external caller and open tab.
|
|
|
273
357
|
/* eslint chrome-extensions/no-invalid-permissions: ['error', { target: 'mv3' }] */
|
|
274
358
|
|
|
275
359
|
{
|
|
276
|
-
-
|
|
277
|
-
+
|
|
278
|
-
-
|
|
279
|
-
+
|
|
360
|
+
- "permissions": ["browserAction", "tabs"],
|
|
361
|
+
+ "permissions": ["tabs"],
|
|
362
|
+
- "optional_permissions": ["history", "pageAction"]
|
|
363
|
+
+ "optional_permissions": ["history"]
|
|
280
364
|
}
|
|
281
365
|
```
|
|
282
366
|
|
|
@@ -324,7 +408,7 @@ If you don't want permission validation at all. For custom permissions from a Ch
|
|
|
324
408
|
! "permissions": []
|
|
325
409
|
}
|
|
326
410
|
|
|
327
|
-
//
|
|
411
|
+
// service-worker.js
|
|
328
412
|
! chrome.cookies.get({ url: 'https://example.com', name: 'x' })
|
|
329
413
|
! chrome.history.search({ text: '' })
|
|
330
414
|
```
|
|
@@ -338,7 +422,7 @@ If you don't want permission validation at all. For custom permissions from a Ch
|
|
|
338
422
|
+ "permissions": ["cookies", "history"]
|
|
339
423
|
}
|
|
340
424
|
|
|
341
|
-
//
|
|
425
|
+
// service-worker.js
|
|
342
426
|
chrome.cookies.get({ url: 'https://example.com', name: 'x' })
|
|
343
427
|
chrome.history.search({ text: '' })
|
|
344
428
|
```
|
|
@@ -379,7 +463,7 @@ If you use APIs through aliases or wrappers that obscure the original call site.
|
|
|
379
463
|
|
|
380
464
|
<details>
|
|
381
465
|
|
|
382
|
-
<summary>Disallows remote or dynamically-evaluated code.</summary>
|
|
466
|
+
<summary>Disallows remote or dynamically-evaluated code (blocked by MV3's CSP at runtime).</summary>
|
|
383
467
|
|
|
384
468
|
#### Options
|
|
385
469
|
|
|
@@ -389,33 +473,32 @@ If you use APIs through aliases or wrappers that obscure the original call site.
|
|
|
389
473
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
|
|
390
474
|
|
|
391
475
|
```diff
|
|
476
|
+
! await import('https://cdn.example.com/lib.js')
|
|
392
477
|
! const run = eval('1 + 1')
|
|
393
478
|
! setTimeout('doThing()', 1000)
|
|
394
|
-
!
|
|
479
|
+
! chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
|
|
395
480
|
```
|
|
396
481
|
|
|
397
482
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
398
483
|
|
|
399
484
|
```diff
|
|
485
|
+
- await import('https://cdn.example.com/lib.js')
|
|
486
|
+
+ await import('./lib.js')
|
|
400
487
|
- const run = eval('1 + 1')
|
|
401
488
|
+ const run = 1 + 1
|
|
402
489
|
- setTimeout('doThing()', 1000)
|
|
403
490
|
+ setTimeout(doThing, 1000)
|
|
404
|
-
-
|
|
405
|
-
+
|
|
491
|
+
- chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
|
|
492
|
+
+ chrome.scripting.executeScript({ target: { tabId }, func: doThing, args: [value] })
|
|
406
493
|
```
|
|
407
494
|
|
|
408
|
-
#### When not to use it
|
|
409
|
-
|
|
410
|
-
Never (MV3 CSP blocks remote code at runtime).
|
|
411
|
-
|
|
412
495
|
#### Introduced in
|
|
413
496
|
|
|
414
497
|
<a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/releases/tag/v1.0.0">v1.0.0</a> (October 2, 2026)
|
|
415
498
|
|
|
416
499
|
#### See also
|
|
417
500
|
|
|
418
|
-
- [`
|
|
501
|
+
- [`require-mv3`](#require-mv3)
|
|
419
502
|
- [`valid-mv3-schema`](#valid-mv3-schema)
|
|
420
503
|
|
|
421
504
|
<div align="center">
|
|
@@ -430,7 +513,7 @@ Never (MV3 CSP blocks remote code at runtime).
|
|
|
430
513
|
|
|
431
514
|
<h3 id="no-unused-permissions">
|
|
432
515
|
<code>no-unused-permissions</code>
|
|
433
|
-
<a title="
|
|
516
|
+
<a title="Included in recommended + strict configs" href="#usage">
|
|
434
517
|
<img height=18 alt="[Recommended]" src="https://img.shields.io/badge/Recommended-f1f20a?style=flat-square" align="middle"></a>
|
|
435
518
|
<a title="Copy rule URL" target="_blank"
|
|
436
519
|
href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23no-unused-permissions">
|
|
@@ -449,7 +532,7 @@ Never (MV3 CSP blocks remote code at runtime).
|
|
|
449
532
|
! "permissions": ["tabs", "downloads"]
|
|
450
533
|
}
|
|
451
534
|
|
|
452
|
-
//
|
|
535
|
+
// service-worker.js
|
|
453
536
|
chrome.tabs.create({ url: 'https://example.com' })
|
|
454
537
|
```
|
|
455
538
|
|
|
@@ -462,7 +545,7 @@ Never (MV3 CSP blocks remote code at runtime).
|
|
|
462
545
|
+ "permissions": ["tabs"]
|
|
463
546
|
}
|
|
464
547
|
|
|
465
|
-
//
|
|
548
|
+
// service-worker.js
|
|
466
549
|
chrome.tabs.create({ url: 'https://example.com' })
|
|
467
550
|
```
|
|
468
551
|
|
|
@@ -505,7 +588,6 @@ If you access the granted API through an alias or computed property rather than
|
|
|
505
588
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
|
|
506
589
|
|
|
507
590
|
```diff
|
|
508
|
-
// manifest.json
|
|
509
591
|
{
|
|
510
592
|
! "manifest_version": 2,
|
|
511
593
|
"name": "My Extension",
|
|
@@ -516,7 +598,6 @@ If you access the granted API through an alias or computed property rather than
|
|
|
516
598
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
517
599
|
|
|
518
600
|
```diff
|
|
519
|
-
// manifest.json
|
|
520
601
|
{
|
|
521
602
|
- "manifest_version": 2,
|
|
522
603
|
+ "manifest_version": 3,
|
|
@@ -564,11 +645,11 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
|
|
|
564
645
|
|
|
565
646
|
```diff
|
|
566
647
|
{
|
|
567
|
-
|
|
568
|
-
!
|
|
569
|
-
!
|
|
570
|
-
!
|
|
571
|
-
!
|
|
648
|
+
"manifest_version": 3,
|
|
649
|
+
! "browser_action": {},
|
|
650
|
+
! "page_action": {},
|
|
651
|
+
! "background": { "scripts": ["background.js"], "persistent": true },
|
|
652
|
+
! "content_security_policy": "script-src 'self' 'unsafe-eval'"
|
|
572
653
|
}
|
|
573
654
|
```
|
|
574
655
|
|
|
@@ -576,14 +657,14 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
|
|
|
576
657
|
|
|
577
658
|
```diff
|
|
578
659
|
{
|
|
579
|
-
|
|
580
|
-
-
|
|
581
|
-
-
|
|
582
|
-
+
|
|
583
|
-
-
|
|
584
|
-
+
|
|
585
|
-
-
|
|
586
|
-
+
|
|
660
|
+
"manifest_version": 3,
|
|
661
|
+
- "browser_action": {},
|
|
662
|
+
- "page_action": {},
|
|
663
|
+
+ "action": {},
|
|
664
|
+
- "background": { "scripts": ["background.js"], "persistent": true },
|
|
665
|
+
+ "background": { "service_worker": "service-worker.js" },
|
|
666
|
+
- "content_security_policy": "script-src 'self' 'unsafe-eval'"
|
|
667
|
+
+ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }
|
|
587
668
|
}
|
|
588
669
|
```
|
|
589
670
|
|
|
@@ -629,7 +710,7 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
|
|
|
629
710
|
! (function init() { chrome.runtime.onMessage.addListener(handler) })()
|
|
630
711
|
! setInterval(() => {}, 5000)
|
|
631
712
|
! const loc = window.location
|
|
632
|
-
! localStorage.setItem('key', '
|
|
713
|
+
! localStorage.setItem('key', 'value')
|
|
633
714
|
```
|
|
634
715
|
|
|
635
716
|
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
@@ -655,7 +736,7 @@ If your background context isn't a service worker.
|
|
|
655
736
|
|
|
656
737
|
#### See also
|
|
657
738
|
|
|
658
|
-
- [`
|
|
739
|
+
- [`require-mv3`](#require-mv3)
|
|
659
740
|
- [`no-remote-code`](#no-remote-code)
|
|
660
741
|
|
|
661
742
|
<div align="center">
|
|
@@ -664,11 +745,81 @@ If your background context isn't a service worker.
|
|
|
664
745
|
|
|
665
746
|
</div>
|
|
666
747
|
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
</details>
|
|
751
|
+
|
|
752
|
+
<h3 id="valid-version-field">
|
|
753
|
+
<code>valid-version-field</code>
|
|
754
|
+
<a title="Included in recommended config" href="#usage">
|
|
755
|
+
<img height=18 alt="[Recommended]" src="https://img.shields.io/badge/Recommended-f1f20a?style=flat-square" align="middle"></a>
|
|
756
|
+
<a title="Accepts array w/ options" href="#usage">
|
|
757
|
+
<img height=18 alt="[Configurable]" src="https://img.shields.io/badge/Configurable-black?style=flat-square" align="middle"></a>
|
|
758
|
+
<a title="Copy rule URL" target="_blank"
|
|
759
|
+
href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23valid-version-field">
|
|
760
|
+
<img height=16 width=21 alt="[Copy rule URL]" src="https://api.iconify.design/mdi/content-copy.svg?color=%23cccccc" align="middle"></a>
|
|
761
|
+
</h3>
|
|
762
|
+
|
|
763
|
+
<details>
|
|
764
|
+
|
|
765
|
+
<summary>Validates the manifest <code>version</code> field.</summary>
|
|
766
|
+
|
|
767
|
+
#### Options
|
|
768
|
+
|
|
769
|
+
- `versionType` (default `'chrome'`) – version format to enforce (`'chrome'`, `'semver'`, `'date'`)
|
|
770
|
+
- `allowFutureDate` (default `false`) – allow `version` dates later than the current date
|
|
771
|
+
- `timezone` (default `''`) – <a target="_blank" href="https://data.iana.org/time-zones/tzdb/zone1970.tab">IANA timezone</a> used to determine the current date (when omitted, only year/month are enforced)
|
|
772
|
+
|
|
773
|
+
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
|
|
774
|
+
|
|
775
|
+
```diff
|
|
776
|
+
/* eslint chrome-extensions/valid-version-field: ['error', { versionType: 'date' }] */
|
|
777
|
+
|
|
778
|
+
// manifest.json
|
|
779
|
+
{
|
|
780
|
+
"manifest_version": 3,
|
|
781
|
+
! "version": "2026.1.111",
|
|
782
|
+
"name": "My Extension"
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
#### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
|
|
787
|
+
|
|
788
|
+
```diff
|
|
789
|
+
/* eslint chrome-extensions/valid-version-field: ['error', { versionType: 'date' }] */
|
|
790
|
+
|
|
791
|
+
// manifest.json
|
|
792
|
+
{
|
|
793
|
+
"manifest_version": 3,
|
|
794
|
+
- "version": "2026.1.111",
|
|
795
|
+
+ "version": "2026.10.3",
|
|
796
|
+
"name": "My Extension"
|
|
797
|
+
}
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
#### When not to use it
|
|
801
|
+
|
|
802
|
+
If you don't care about version format consistency, or your version is injected at build time.
|
|
803
|
+
|
|
804
|
+
#### Introduced in
|
|
805
|
+
|
|
806
|
+
<a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/releases/tag/v2.0.0">v2.0.0</a> (October 4, 2026)
|
|
807
|
+
|
|
808
|
+
#### See also
|
|
809
|
+
|
|
810
|
+
- <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/manifest/version">Chrome for Developers — `version`</a>
|
|
811
|
+
|
|
812
|
+
<div align="center">
|
|
813
|
+
|
|
814
|
+
[Back to index ↑][epc-rules]
|
|
815
|
+
|
|
816
|
+
</div>
|
|
817
|
+
|
|
667
818
|
</details>
|
|
668
819
|
|
|
669
820
|
---
|
|
670
821
|
|
|
671
|
-
## <img
|
|
822
|
+
## <img width=30 align="top" src="https://api.iconify.design/boxicons/code-alt-filled.svg?color=%232b2b2a"> Shield embed
|
|
672
823
|
|
|
673
824
|
<a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
|
|
674
825
|
<img height=32 alt="[Linted by eslint-plugin-chrome-extensions]" src="https://img.shields.io/badge/Linted_by-eslint--plugin--chrome--extensions-black?logo=eslint&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
@@ -676,7 +827,7 @@ If your background context isn't a service worker.
|
|
|
676
827
|
#### HTML
|
|
677
828
|
|
|
678
829
|
```html
|
|
679
|
-
<a href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
|
|
830
|
+
<a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
|
|
680
831
|
<img height=32 alt="[Linted by eslint-plugin-chrome-extensions]" src="https://img.shields.io/badge/Linted_by-eslint--plugin--chrome--extensions-black?logo=eslint&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
681
832
|
```
|
|
682
833
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-chrome-extensions",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "ESLint rules for Chrome extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"chromium"
|
|
59
59
|
],
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"eslint": ">=9.
|
|
61
|
+
"eslint": ">=9.6.0",
|
|
62
|
+
"@eslint/json": ">=0.1.0"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
64
65
|
"@e18e/eslint-plugin": "^0.8.1",
|
|
@@ -66,11 +67,11 @@
|
|
|
66
67
|
"@eslint/json": "^2.1.0",
|
|
67
68
|
"@eslint/markdown": "^8.0.3",
|
|
68
69
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
69
|
-
"eslint": "^10.
|
|
70
|
+
"eslint": "^10.12.0",
|
|
70
71
|
"eslint-plugin-constructors": "^1.4.0",
|
|
71
72
|
"eslint-plugin-eslint-plugin": "^7.6.2",
|
|
72
73
|
"eslint-plugin-import-x": "^4.17.1",
|
|
73
|
-
"eslint-plugin-n": "^18.4.
|
|
74
|
+
"eslint-plugin-n": "^18.4.1",
|
|
74
75
|
"eslint-plugin-no-secrets": "^2.3.3",
|
|
75
76
|
"eslint-plugin-no-unsanitized": "^4.1.5",
|
|
76
77
|
"eslint-plugin-promise": "^7.3.0",
|
package/src/data/manifest.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import globals from 'globals'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export const
|
|
3
|
+
const serviceWorkerGlobals = new Set(Object.keys(globals.serviceworker || {}))
|
|
4
|
+
export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
|
|
5
|
+
|
|
6
|
+
export const forbiddenCSPdirectives = [
|
|
5
7
|
`'unsafe-eval'`, `'unsafe-inline'`, `'unsafe-hashes'`, `'wasm-unsafe-eval'`, 'http://', 'https://']
|
|
6
8
|
|
|
7
|
-
export const
|
|
9
|
+
export const manifestKeyOrder = [
|
|
10
|
+
// platform-required
|
|
11
|
+
'manifest_version', 'name', 'version',
|
|
12
|
+
// CWS-required
|
|
13
|
+
'description', 'icons',
|
|
14
|
+
// optional (alphabetical, per docs)
|
|
15
|
+
'action', 'background', 'chrome_settings_overrides', 'chrome_url_overrides',
|
|
16
|
+
'commands', 'content_scripts', 'content_security_policy',
|
|
17
|
+
'cross_origin_embedder_policy', 'cross_origin_opener_policy',
|
|
18
|
+
'declarative_net_request', 'default_locale', 'devtools_page', 'export',
|
|
19
|
+
'externally_connectable', 'homepage_url', 'host_permissions', 'import',
|
|
20
|
+
'incognito', 'key', 'mime_types_handler', 'minimum_chrome_version',
|
|
21
|
+
'oauth2', 'omnibox', 'optional_host_permissions', 'optional_permissions',
|
|
22
|
+
'options_page', 'options_ui', 'permissions', 'requirements', 'sandbox',
|
|
23
|
+
'short_name', 'side_panel', 'storage', 'tts_engine', 'update_url',
|
|
24
|
+
'version_name', 'web_accessible_resources'
|
|
25
|
+
]
|
|
8
26
|
|
|
9
|
-
const
|
|
10
|
-
export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
|
|
27
|
+
export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
|
|
11
28
|
|
|
12
29
|
export const remoteCodePatterns = [
|
|
13
30
|
'eval', 'new-function', 'string-timer', 'dynamic-import', 'remote-script-src', 'execute-script-url']
|
|
31
|
+
|
|
32
|
+
export const serviceWorkerKeepaliveMinMS = 30000
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { bundleRules } from './utils.js'
|
|
2
|
+
|
|
3
|
+
import manifestKeyOrder from './rules/manifest-key-order.js'
|
|
1
4
|
import noContentStyleLeak from './rules/no-content-style-leak.js'
|
|
2
5
|
import noDeprecatedAPIs from './rules/no-deprecated-apis.js'
|
|
3
6
|
import noInsecureMessaging from './rules/no-insecure-messaging.js'
|
|
@@ -8,19 +11,22 @@ import noUnusedPermissions from './rules/no-unused-permissions.js'
|
|
|
8
11
|
import requireMV3 from './rules/require-mv3.js'
|
|
9
12
|
import validMV3schema from './rules/valid-mv3-schema.js'
|
|
10
13
|
import validServiceWorker from './rules/valid-service-worker.js'
|
|
14
|
+
import validVersionField from './rules/valid-version-field.js'
|
|
11
15
|
|
|
12
16
|
const plugin = {
|
|
13
17
|
rules: {
|
|
14
|
-
'
|
|
15
|
-
'valid-mv3-schema': validMV3schema,
|
|
16
|
-
'no-deprecated-apis': noDeprecatedAPIs,
|
|
17
|
-
'no-remote-code': noRemoteCode,
|
|
18
|
-
'valid-service-worker': validServiceWorker,
|
|
18
|
+
'manifest-key-order': manifestKeyOrder,
|
|
19
19
|
'no-content-style-leak': noContentStyleLeak,
|
|
20
|
+
'no-deprecated-apis': noDeprecatedAPIs,
|
|
21
|
+
'no-insecure-messaging': noInsecureMessaging,
|
|
20
22
|
'no-invalid-permissions': noInvalidPermissions,
|
|
21
23
|
'no-missing-permissions': noMissingPermissions,
|
|
24
|
+
'no-remote-code': noRemoteCode,
|
|
22
25
|
'no-unused-permissions': noUnusedPermissions,
|
|
23
|
-
'
|
|
26
|
+
'require-mv3': requireMV3,
|
|
27
|
+
'valid-mv3-schema': validMV3schema,
|
|
28
|
+
'valid-service-worker': validServiceWorker,
|
|
29
|
+
'valid-version-field': validVersionField
|
|
24
30
|
}
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -30,28 +36,19 @@ for (const [ruleId, rule] of Object.entries(plugin.rules))
|
|
|
30
36
|
plugin.configs = {
|
|
31
37
|
recommended: {
|
|
32
38
|
plugins: { 'chrome-extensions': plugin },
|
|
33
|
-
rules:
|
|
34
|
-
'chrome-extensions/require-mv3': 'error',
|
|
35
|
-
'chrome-extensions/valid-mv3-schema': 'error',
|
|
36
|
-
'chrome-extensions/no-deprecated-apis': 'error',
|
|
37
|
-
'chrome-extensions/no-remote-code': 'error',
|
|
38
|
-
'chrome-extensions/valid-service-worker': 'error',
|
|
39
|
-
'chrome-extensions/no-invalid-permissions': 'error',
|
|
40
|
-
'chrome-extensions/no-missing-permissions': 'error',
|
|
41
|
-
'chrome-extensions/no-unused-permissions': 'error',
|
|
42
|
-
'chrome-extensions/no-insecure-messaging': 'error'
|
|
43
|
-
}
|
|
39
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.docs.recommended, 'chrome-extensions')
|
|
44
40
|
},
|
|
45
41
|
strict: {
|
|
46
42
|
plugins: { 'chrome-extensions': plugin },
|
|
47
|
-
rules:
|
|
48
|
-
.map(ruleId => [`chrome-extensions/${ruleId}`, 'error']))
|
|
43
|
+
rules: bundleRules(plugin.rules, () => true, 'chrome-extensions')
|
|
49
44
|
},
|
|
50
45
|
problem: {
|
|
51
46
|
plugins: { 'chrome-extensions': plugin },
|
|
52
|
-
rules:
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.type == 'problem', 'chrome-extensions')
|
|
48
|
+
},
|
|
49
|
+
manifest: {
|
|
50
|
+
plugins: { 'chrome-extensions': plugin },
|
|
51
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.docs.manifest, 'chrome-extensions')
|
|
55
52
|
}
|
|
56
53
|
}
|
|
57
54
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { manifestKeyOrder } from '../data/manifest.js'
|
|
2
|
+
import { getFilename } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
5
|
+
|
|
6
|
+
function keyOf(member) {
|
|
7
|
+
const keyNode = member.name || member.key
|
|
8
|
+
return keyNode?.value ?? keyNode?.name
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function findViolation(members, keys) {
|
|
12
|
+
const ranks = new Map(keys.map((key, index) => [key, index])),
|
|
13
|
+
known = members.map(member => {
|
|
14
|
+
const key = keyOf(member)
|
|
15
|
+
return { member, key, rank: ranks.get(key) }
|
|
16
|
+
}).filter(item => item.rank !== undefined)
|
|
17
|
+
for (let i = 1; i < known.length; i++)
|
|
18
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
19
|
+
if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
meta: {
|
|
24
|
+
type: 'layout',
|
|
25
|
+
docs: {
|
|
26
|
+
description: 'Enforce an order for manifest keys',
|
|
27
|
+
category: 'Stylistic Issues',
|
|
28
|
+
recommended: false,
|
|
29
|
+
manifest: true
|
|
30
|
+
},
|
|
31
|
+
fixable: 'code',
|
|
32
|
+
schema: [{
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
order: {
|
|
36
|
+
type: 'array', items: { type: 'string' },
|
|
37
|
+
description: 'Manifest keys in preferred order; unspecified keys are ignored'
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
additionalProperties: false
|
|
41
|
+
}],
|
|
42
|
+
defaultOptions: [{ order: [] }],
|
|
43
|
+
messages: { wrongOrder: '`{{first}}` should appear before `{{second}}`' }
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
create(context) {
|
|
47
|
+
if (!re.manifestFilename.test(getFilename(context))) return {}
|
|
48
|
+
const custom = context.options[0]?.order ?? [],
|
|
49
|
+
master = custom.length ? manifestKeyOrder.filter(key => !custom.includes(key))
|
|
50
|
+
: manifestKeyOrder
|
|
51
|
+
return {
|
|
52
|
+
'Document:exit'(document) {
|
|
53
|
+
const root = document.body ; if (root?.type != 'Object') return
|
|
54
|
+
const members = root.members || root.properties || []
|
|
55
|
+
const violation = findViolation(members, master) ?? (custom.length && findViolation(members, custom))
|
|
56
|
+
if (!violation) return
|
|
57
|
+
const [first, second] = violation
|
|
58
|
+
context.report({
|
|
59
|
+
node: second.member, messageId: 'wrongOrder',
|
|
60
|
+
data: { first: second.key, second: first.key },
|
|
61
|
+
fix(fixer) {
|
|
62
|
+
const sourceCode = context.sourceCode,
|
|
63
|
+
firstText = sourceCode.text.slice(first.member.range[0], first.member.range[1]),
|
|
64
|
+
secondText = sourceCode.text.slice(second.member.range[0], second.member.range[1]),
|
|
65
|
+
between = sourceCode.text.slice(first.member.range[1], second.member.range[0])
|
|
66
|
+
return fixer.replaceTextRange(
|
|
67
|
+
[first.member.range[0], second.member.range[1]],
|
|
68
|
+
secondText + between + firstText
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -10,7 +10,7 @@ export default {
|
|
|
10
10
|
},
|
|
11
11
|
schema: [],
|
|
12
12
|
messages: {
|
|
13
|
-
styleLeak: 'Inject styles
|
|
13
|
+
styleLeak: 'Inject styles into a `shadowRoot` instead of the host document.'
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isValidPermission } from '../data/permissions.js'
|
|
2
|
-
import { readManifest } from '../utils.js'
|
|
2
|
+
import { getFilename, readManifest } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
3
5
|
|
|
4
6
|
export default {
|
|
5
7
|
meta: {
|
|
@@ -7,7 +9,8 @@ export default {
|
|
|
7
9
|
docs: {
|
|
8
10
|
description: 'Disallow invalid or unsupported manifest permission names',
|
|
9
11
|
category: 'Possible Errors',
|
|
10
|
-
recommended: true
|
|
12
|
+
recommended: true,
|
|
13
|
+
manifest: true
|
|
11
14
|
},
|
|
12
15
|
defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],
|
|
13
16
|
schema: [{
|
|
@@ -40,9 +43,27 @@ export default {
|
|
|
40
43
|
const opts = context.options[0] || {},
|
|
41
44
|
target = opts.target || 'mv3',
|
|
42
45
|
allowed = new Set(Array.isArray(opts.allowedPermissions) ? opts.allowedPermissions
|
|
43
|
-
: opts.allowedPermissions ? [opts.allowedPermissions] : [])
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
: opts.allowedPermissions ? [opts.allowedPermissions] : [])
|
|
47
|
+
function checkPermission(node, permission) {
|
|
48
|
+
if (typeof permission != 'string') return
|
|
49
|
+
if (allowed.has(permission) || isValidPermission(permission, target)) return
|
|
50
|
+
context.report({ node, messageId: 'invalidPermission', data: { permission, target }})
|
|
51
|
+
}
|
|
52
|
+
if (re.manifestFilename.test(getFilename(context))) // lint it directly
|
|
53
|
+
return {
|
|
54
|
+
'*'(node) {
|
|
55
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
56
|
+
const keyName = keyNode.value ?? keyNode.name
|
|
57
|
+
if (keyName != 'permissions' && keyName != 'optional_permissions') return
|
|
58
|
+
const arrNode = node.value ; if (arrNode?.type != 'Array') return
|
|
59
|
+
const elements = arrNode.elements || arrNode.items || []
|
|
60
|
+
for (const el of elements) {
|
|
61
|
+
const valueNode = el.value || el
|
|
62
|
+
checkPermission(valueNode, valueNode?.value)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const manifest = readManifest(context) ; if (!manifest) return {}
|
|
46
67
|
const all = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
|
|
47
68
|
return {
|
|
48
69
|
'Program:exit'() {
|
|
@@ -32,11 +32,11 @@ export default {
|
|
|
32
32
|
additionalProperties: false
|
|
33
33
|
}],
|
|
34
34
|
messages: {
|
|
35
|
-
eval: '`eval()` is forbidden
|
|
36
|
-
newFunction: '`new Function()` is forbidden
|
|
37
|
-
stringTimer: 'String-argument `{{fn}}` is forbidden
|
|
38
|
-
dynamicImport: 'Remote dynamic `import()` is forbidden
|
|
39
|
-
remoteScriptSrc: 'Injecting remote `<script src>` is forbidden
|
|
35
|
+
eval: '`eval()` is forbidden under MV3\'s CSP - refactor to use static code.',
|
|
36
|
+
newFunction: '`new Function()` is forbidden under MV3\'s CSP - refactor to use a real function.',
|
|
37
|
+
stringTimer: 'String-argument `{{fn}}` is forbidden under MV3\'s CSP - pass a function reference instead.',
|
|
38
|
+
dynamicImport: 'Remote dynamic `import()` is forbidden under MV3\'s CSP - import local modules w/ a relative path.',
|
|
39
|
+
remoteScriptSrc: 'Injecting remote `<script src>` is forbidden under MV3\'s CSP - use `chrome.runtime.getURL()` for bundled scripts.',
|
|
40
40
|
executeScriptURL: '`chrome.scripting.executeScript` must use local `files`, not a remote URL.',
|
|
41
41
|
executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization - pass them via `args`.'
|
|
42
42
|
}
|
|
@@ -1,15 +1,40 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { apiPermissionMap, findPermission } from '../data/apis.js'
|
|
4
|
+
import { findExtensionRoot, getFilename, readManifest } from '../utils.js'
|
|
3
5
|
|
|
4
|
-
const
|
|
6
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
7
|
+
|
|
8
|
+
function collectUsedAPIs(manifest, root) {
|
|
9
|
+
const files = new Set()
|
|
10
|
+
if (manifest.background?.service_worker) files.add(manifest.background.service_worker)
|
|
11
|
+
for (const script of manifest.background?.scripts || []) files.add(script)
|
|
12
|
+
for (const contentScript of manifest.content_scripts || [])
|
|
13
|
+
for (const file of contentScript.js || []) files.add(file)
|
|
14
|
+
if (manifest.action?.default_popup) files.add(manifest.action.default_popup)
|
|
15
|
+
if (manifest.options_ui?.page) files.add(manifest.options_ui.page)
|
|
16
|
+
if (manifest.devtools_page) files.add(manifest.devtools_page)
|
|
17
|
+
const re = { /* eslint-disable-next-line security/detect-unsafe-regex */
|
|
18
|
+
chromeAPI: /\bchrome\.\w+(?:\.\w+)*/g }
|
|
19
|
+
const used = new Set()
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
try {
|
|
22
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
23
|
+
const src = fs.readFileSync(path.resolve(root, file), 'utf8')
|
|
24
|
+
for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
|
|
25
|
+
} catch {} // eslint-disable-line no-empty
|
|
26
|
+
}
|
|
27
|
+
return used
|
|
28
|
+
}
|
|
5
29
|
|
|
6
30
|
export default {
|
|
7
31
|
meta: {
|
|
8
|
-
type: '
|
|
32
|
+
type: 'problem',
|
|
9
33
|
docs: {
|
|
10
|
-
description: 'Flag manifest permissions whose APIs are never used
|
|
11
|
-
category: '
|
|
12
|
-
recommended: true
|
|
34
|
+
description: 'Flag manifest permissions whose APIs are never used',
|
|
35
|
+
category: 'Possible Errors',
|
|
36
|
+
recommended: true,
|
|
37
|
+
manifest: true
|
|
13
38
|
},
|
|
14
39
|
schema: [],
|
|
15
40
|
messages: {
|
|
@@ -18,20 +43,40 @@ export default {
|
|
|
18
43
|
},
|
|
19
44
|
|
|
20
45
|
create(context) {
|
|
46
|
+
if (!re.manifestFilename.test(getFilename(context))) return {}
|
|
21
47
|
const manifest = readManifest(context) ; if (!manifest) return {}
|
|
48
|
+
const root = findExtensionRoot(context) ; if (!root) return {}
|
|
49
|
+
const usedAPIs = collectUsedAPIs(manifest, root)
|
|
50
|
+
const permissionNodes = new Map()
|
|
51
|
+
function isUsed(permission) {
|
|
52
|
+
let known = false
|
|
53
|
+
for (const [, perm] of apiPermissionMap)
|
|
54
|
+
if (perm == permission) { known = true ; break }
|
|
55
|
+
if (!known) return true // custom permission → not our business
|
|
56
|
+
for (const api of usedAPIs)
|
|
57
|
+
if (findPermission(api) == permission) return true
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
22
60
|
return {
|
|
23
|
-
'
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
'*'(node) {
|
|
62
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
63
|
+
const keyName = keyNode.value ?? keyNode.name
|
|
64
|
+
if (keyName != 'permissions' && keyName != 'optional_permissions') return
|
|
65
|
+
const arrNode = node.value ; if (arrNode?.type != 'Array') return
|
|
66
|
+
const elements = arrNode.elements || arrNode.items || []
|
|
67
|
+
for (const el of elements) {
|
|
68
|
+
const valueNode = el.value || el
|
|
69
|
+
const perm = valueNode?.value
|
|
70
|
+
if (typeof perm != 'string') continue
|
|
71
|
+
if (!permissionNodes.has(perm)) permissionNodes.set(perm, [])
|
|
72
|
+
permissionNodes.get(perm).push(valueNode)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
'Document:exit'() {
|
|
76
|
+
for (const [permission, nodes] of permissionNodes) {
|
|
77
|
+
if (isUsed(permission)) continue
|
|
78
|
+
for (const n of nodes)
|
|
79
|
+
context.report({ node: n, messageId: 'unusedPermission', data: { permission }})
|
|
35
80
|
}
|
|
36
81
|
}
|
|
37
82
|
}
|
package/src/rules/require-mv3.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { readManifest } from '../utils.js'
|
|
1
|
+
import { getFilename, readManifest } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
2
4
|
|
|
3
5
|
export default {
|
|
4
6
|
meta: {
|
|
@@ -6,7 +8,8 @@ export default {
|
|
|
6
8
|
docs: {
|
|
7
9
|
description: 'Require manifest_version 3',
|
|
8
10
|
category: 'Possible Errors',
|
|
9
|
-
recommended: true
|
|
11
|
+
recommended: true,
|
|
12
|
+
manifest: true
|
|
10
13
|
},
|
|
11
14
|
schema: [],
|
|
12
15
|
messages: {
|
|
@@ -15,6 +18,21 @@ export default {
|
|
|
15
18
|
},
|
|
16
19
|
|
|
17
20
|
create(context) {
|
|
21
|
+
if (re.manifestFilename.test(getFilename(context))) { // lint it directly
|
|
22
|
+
let foundVersion = null, versionNode = null
|
|
23
|
+
return {
|
|
24
|
+
'*'(node) {
|
|
25
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
26
|
+
if ((keyNode.value ?? keyNode.name) != 'manifest_version') return
|
|
27
|
+
foundVersion = node.value?.value
|
|
28
|
+
versionNode = node.value || keyNode
|
|
29
|
+
},
|
|
30
|
+
'*:exit'(node) {
|
|
31
|
+
if (node.type != 'Document' || foundVersion == 3) return
|
|
32
|
+
context.report({ node: versionNode || node, messageId: 'notMV3' })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version == 3) return {}
|
|
19
37
|
return { Program(node) { context.report({ node, messageId: 'notMV3' }) }}
|
|
20
38
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { mv2OnlyKeys,
|
|
2
|
-
import { readManifest } from '../utils.js'
|
|
1
|
+
import { mv2OnlyKeys, forbiddenCSPdirectives } from '../data/manifest.js'
|
|
2
|
+
import { getFilename, readManifest } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
3
5
|
|
|
4
6
|
export default {
|
|
5
7
|
meta: {
|
|
@@ -7,7 +9,8 @@ export default {
|
|
|
7
9
|
docs: {
|
|
8
10
|
description: 'Validate manifest against the MV3 schema',
|
|
9
11
|
category: 'Possible Errors',
|
|
10
|
-
recommended: true
|
|
12
|
+
recommended: true,
|
|
13
|
+
manifest: true
|
|
11
14
|
},
|
|
12
15
|
schema: [],
|
|
13
16
|
messages: {
|
|
@@ -18,6 +21,51 @@ export default {
|
|
|
18
21
|
},
|
|
19
22
|
|
|
20
23
|
create(context) {
|
|
24
|
+
if (re.manifestFilename.test(getFilename(context))) { // lint it directly
|
|
25
|
+
const ancestors = [], pendingReports = []
|
|
26
|
+
let isMV3 = false
|
|
27
|
+
const keyOf = n => {
|
|
28
|
+
const k = n?.name || n?.key
|
|
29
|
+
return k?.value ?? k?.name
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
'*'(node) {
|
|
33
|
+
const keyName = keyOf(node)
|
|
34
|
+
if (keyName) {
|
|
35
|
+
const isTopLevel = ancestors.length == 2
|
|
36
|
+
&& ancestors[0].type == 'Document'
|
|
37
|
+
&& ancestors[1].type == 'Object'
|
|
38
|
+
const inBackground = ancestors.some(a => keyOf(a) == 'background')
|
|
39
|
+
const inCSP = ancestors.some(a => keyOf(a) == 'content_security_policy')
|
|
40
|
+
if (keyName == 'manifest_version' && node.value?.value == 3) isMV3 = true
|
|
41
|
+
if (isTopLevel && mv2OnlyKeys.has(keyName))
|
|
42
|
+
pendingReports.push({ node, messageId: 'mv2Key',
|
|
43
|
+
data: { key: keyName, reason: 'MV2-only key' }})
|
|
44
|
+
if (inBackground && keyName == 'scripts')
|
|
45
|
+
pendingReports.push({ node, messageId: 'mv2Key',
|
|
46
|
+
data: { key: 'background.scripts', reason: 'use background.service_worker' }})
|
|
47
|
+
if (inBackground && keyName == 'persistent')
|
|
48
|
+
pendingReports.push({ node, messageId: 'mv2Key',
|
|
49
|
+
data: { key: 'background.persistent', reason: 'MV3 service workers are never persistent' }})
|
|
50
|
+
if (isTopLevel && keyName == 'content_security_policy' && node.value?.type == 'String')
|
|
51
|
+
pendingReports.push({ node: node.value, messageId: 'wrongCSPType' })
|
|
52
|
+
if (inCSP && keyName == 'extension_pages' && typeof node.value?.value == 'string')
|
|
53
|
+
for (const directive of forbiddenCSPdirectives)
|
|
54
|
+
if (node.value.value.includes(directive))
|
|
55
|
+
pendingReports.push({ node: node.value, messageId: 'unsafeCSP',
|
|
56
|
+
data: { directive }})
|
|
57
|
+
}
|
|
58
|
+
ancestors.push(node)
|
|
59
|
+
},
|
|
60
|
+
'*:exit'(node) {
|
|
61
|
+
ancestors.pop()
|
|
62
|
+
if (node.type == 'Document') {
|
|
63
|
+
if (!isMV3) return
|
|
64
|
+
for (const r of pendingReports) context.report(r)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
21
69
|
const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version != 3) return {}
|
|
22
70
|
return {
|
|
23
71
|
Program(node) {
|
|
@@ -35,7 +83,7 @@ export default {
|
|
|
35
83
|
context.report({ node, messageId: 'wrongCSPType' })
|
|
36
84
|
const cspPages = csp?.extension_pages
|
|
37
85
|
if (typeof cspPages == 'string')
|
|
38
|
-
for (const directive of
|
|
86
|
+
for (const directive of forbiddenCSPdirectives)
|
|
39
87
|
if (cspPages.includes(directive))
|
|
40
88
|
context.report({ node, messageId: 'unsafeCSP', data: { directive }})
|
|
41
89
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { getFilename, readManifest } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const re = { /* eslint-disable security/detect-unsafe-regex */
|
|
4
|
+
chromeParts: /^\d+(?:\.\d+){0,3}$/,
|
|
5
|
+
date: /^\d{4}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:\.\d+)?$/,
|
|
6
|
+
manifestFilename: /manifest\.json$/,
|
|
7
|
+
semver: /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/
|
|
8
|
+
} /* eslint-enable security/detect-unsafe-regex */
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
meta: {
|
|
12
|
+
type: 'problem',
|
|
13
|
+
docs: {
|
|
14
|
+
description: 'Validate the manifest version field format',
|
|
15
|
+
category: 'Possible Errors',
|
|
16
|
+
recommended: true,
|
|
17
|
+
manifest: true
|
|
18
|
+
},
|
|
19
|
+
schema: [{
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
versionType: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['chrome', 'semver', 'date'],
|
|
25
|
+
description: 'Version format to enforce (default: chrome)'
|
|
26
|
+
},
|
|
27
|
+
allowFutureDate: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
description: 'Allow version dates later than the current date (default: false)'
|
|
30
|
+
},
|
|
31
|
+
timezone: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'IANA timezone for determining the current date (when omitted, only year and month are checked)'
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: false
|
|
37
|
+
}],
|
|
38
|
+
defaultOptions: [{}],
|
|
39
|
+
messages: {
|
|
40
|
+
invalidVersion: 'Invalid manifest `version`: must be a valid {{versionType}} version (expected format: {{example}})',
|
|
41
|
+
futureVersion: 'Invalid manifest `version`: date cannot be in the future'
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
create(context) {
|
|
46
|
+
const options = context.options[0] || {},
|
|
47
|
+
versionType = options.versionType || 'chrome',
|
|
48
|
+
allowFutureDate = options.allowFutureDate ?? false,
|
|
49
|
+
timezone = options.timezone,
|
|
50
|
+
example = versionType == 'semver' ? '1.2.3'
|
|
51
|
+
: versionType == 'date' ? 'YYYY.M.D or YYYY.M.D.N'
|
|
52
|
+
: '1.2.3 or 1.2.3.4'
|
|
53
|
+
const now = timezone
|
|
54
|
+
? Object.fromEntries(new Intl.DateTimeFormat('en', {
|
|
55
|
+
timeZone: timezone, year: 'numeric', month: 'numeric', day: 'numeric'
|
|
56
|
+
}).formatToParts().filter(({ type }) => ['year', 'month', 'day'].includes(type))
|
|
57
|
+
.map(({ type, value }) => [type, Number(value)]))
|
|
58
|
+
: { year: new Date().getUTCFullYear(), month: new Date().getUTCMonth() +1 }
|
|
59
|
+
function validate(node, version) {
|
|
60
|
+
if (typeof version != 'string') return
|
|
61
|
+
const parts = version.split('.')
|
|
62
|
+
if (!re.chromeParts.test(version) || parts.some(part => Number(part) > 65535))
|
|
63
|
+
return context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
|
|
64
|
+
if (versionType == 'semver') {
|
|
65
|
+
if (!re.semver.test(version))
|
|
66
|
+
context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
|
|
67
|
+
} else if (versionType == 'date') {
|
|
68
|
+
if (!re.date.test(version))
|
|
69
|
+
context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
|
|
70
|
+
else {
|
|
71
|
+
const [year, month, day] = parts.map(Number)
|
|
72
|
+
if (!allowFutureDate && (
|
|
73
|
+
year > now.year
|
|
74
|
+
|| year == now.year && month > now.month
|
|
75
|
+
|| timezone && year == now.year && month == now.month && day > now.day
|
|
76
|
+
)) context.report({ node, messageId: 'futureVersion' })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (re.manifestFilename.test(getFilename(context))) // lint it directly
|
|
81
|
+
return {
|
|
82
|
+
'*'(node) {
|
|
83
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
84
|
+
const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'version') return
|
|
85
|
+
const valueNode = node.value ; if (valueNode?.type != 'String') return
|
|
86
|
+
validate(valueNode, valueNode.value)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
Program(node) {
|
|
91
|
+
validate(node, readManifest(context)?.version)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -4,13 +4,20 @@ import path from 'node:path'
|
|
|
4
4
|
const re = { chromeAPI: /\bchrome(?:\.\w+)+/g }, // eslint-disable-line security/detect-unsafe-regex
|
|
5
5
|
manifestCache = new Map()
|
|
6
6
|
|
|
7
|
+
export function bundleRules(rules, predicate, pluginName) {
|
|
8
|
+
return Object.fromEntries(Object.entries(rules)
|
|
9
|
+
.filter(([, rule]) => predicate(rule))
|
|
10
|
+
.map(([ruleId]) => [`${pluginName}/${ruleId}`, 'error']))
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
export function findExtensionRoot(context) {
|
|
8
14
|
let dir = path.dirname(getFilename(context))
|
|
9
|
-
|
|
10
|
-
while (dir != root) {
|
|
15
|
+
while (dir) {
|
|
11
16
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
12
17
|
if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
|
|
13
|
-
|
|
18
|
+
const parent = path.dirname(dir)
|
|
19
|
+
if (parent == dir) break // reached filesystem root or a stuck relative "."
|
|
20
|
+
dir = parent
|
|
14
21
|
}
|
|
15
22
|
return null
|
|
16
23
|
}
|