eslint-plugin-chrome-extensions 1.0.0 → 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 +230 -92
- 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
|
|
|
@@ -321,10 +405,10 @@ If you don't want permission validation at all. For custom permissions from a Ch
|
|
|
321
405
|
```diff
|
|
322
406
|
// manifest.json
|
|
323
407
|
{
|
|
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
|
```
|
|
@@ -334,11 +418,11 @@ If you don't want permission validation at all. For custom permissions from a Ch
|
|
|
334
418
|
```diff
|
|
335
419
|
// manifest.json
|
|
336
420
|
{
|
|
337
|
-
-
|
|
338
|
-
+
|
|
421
|
+
- "permissions": []
|
|
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,46 +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
|
-
!
|
|
395
|
-
! document.head.append(Object.assign(document.createElement('script'),
|
|
396
|
-
! { src: 'https://cdn.example.com/remote.js' }))
|
|
397
|
-
|
|
398
|
-
const secret = 'abc'
|
|
399
|
-
! chrome.scripting.executeScript({ func: () => alert(secret), target: { tabId } })
|
|
479
|
+
! chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
|
|
400
480
|
```
|
|
401
481
|
|
|
402
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
|
|
403
483
|
|
|
404
484
|
```diff
|
|
485
|
+
- await import('https://cdn.example.com/lib.js')
|
|
486
|
+
+ await import('./lib.js')
|
|
405
487
|
- const run = eval('1 + 1')
|
|
406
488
|
+ const run = 1 + 1
|
|
407
489
|
- setTimeout('doThing()', 1000)
|
|
408
490
|
+ setTimeout(doThing, 1000)
|
|
409
|
-
-
|
|
410
|
-
+
|
|
411
|
-
- document.head.append(Object.assign(document.createElement('script'),
|
|
412
|
-
- { src: 'https://cdn.example.com/remote.js' }))
|
|
413
|
-
+ document.head.append(Object.assign(document.createElement('script'),
|
|
414
|
-
+ { src: chrome.runtime.getURL('inject.js') }))
|
|
415
|
-
|
|
416
|
-
const secret = 'abc'
|
|
417
|
-
- chrome.scripting.executeScript({ func: () => alert(secret), target: { tabId } })
|
|
418
|
-
+ chrome.scripting.executeScript({ func: (s) => alert(s), args: [secret], target: { tabId } })
|
|
491
|
+
- chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
|
|
492
|
+
+ chrome.scripting.executeScript({ target: { tabId }, func: doThing, args: [value] })
|
|
419
493
|
```
|
|
420
494
|
|
|
421
|
-
#### When not to use it
|
|
422
|
-
|
|
423
|
-
Never (MV3 CSP blocks remote code at runtime).
|
|
424
|
-
|
|
425
495
|
#### Introduced in
|
|
426
496
|
|
|
427
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)
|
|
428
498
|
|
|
429
499
|
#### See also
|
|
430
500
|
|
|
431
|
-
- [`
|
|
501
|
+
- [`require-mv3`](#require-mv3)
|
|
432
502
|
- [`valid-mv3-schema`](#valid-mv3-schema)
|
|
433
503
|
|
|
434
504
|
<div align="center">
|
|
@@ -443,7 +513,7 @@ Never (MV3 CSP blocks remote code at runtime).
|
|
|
443
513
|
|
|
444
514
|
<h3 id="no-unused-permissions">
|
|
445
515
|
<code>no-unused-permissions</code>
|
|
446
|
-
<a title="
|
|
516
|
+
<a title="Included in recommended + strict configs" href="#usage">
|
|
447
517
|
<img height=18 alt="[Recommended]" src="https://img.shields.io/badge/Recommended-f1f20a?style=flat-square" align="middle"></a>
|
|
448
518
|
<a title="Copy rule URL" target="_blank"
|
|
449
519
|
href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23no-unused-permissions">
|
|
@@ -459,10 +529,10 @@ Never (MV3 CSP blocks remote code at runtime).
|
|
|
459
529
|
```diff
|
|
460
530
|
// manifest.json
|
|
461
531
|
{
|
|
462
|
-
!
|
|
532
|
+
! "permissions": ["tabs", "downloads"]
|
|
463
533
|
}
|
|
464
534
|
|
|
465
|
-
//
|
|
535
|
+
// service-worker.js
|
|
466
536
|
chrome.tabs.create({ url: 'https://example.com' })
|
|
467
537
|
```
|
|
468
538
|
|
|
@@ -471,11 +541,11 @@ Never (MV3 CSP blocks remote code at runtime).
|
|
|
471
541
|
```diff
|
|
472
542
|
// manifest.json
|
|
473
543
|
{
|
|
474
|
-
-
|
|
475
|
-
+
|
|
544
|
+
- "permissions": ["tabs", "downloads"]
|
|
545
|
+
+ "permissions": ["tabs"]
|
|
476
546
|
}
|
|
477
547
|
|
|
478
|
-
//
|
|
548
|
+
// service-worker.js
|
|
479
549
|
chrome.tabs.create({ url: 'https://example.com' })
|
|
480
550
|
```
|
|
481
551
|
|
|
@@ -518,23 +588,21 @@ If you access the granted API through an alias or computed property rather than
|
|
|
518
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
|
|
519
589
|
|
|
520
590
|
```diff
|
|
521
|
-
// manifest.json
|
|
522
591
|
{
|
|
523
|
-
!
|
|
524
|
-
|
|
525
|
-
|
|
592
|
+
! "manifest_version": 2,
|
|
593
|
+
"name": "My Extension",
|
|
594
|
+
"version": "1.0.0"
|
|
526
595
|
}
|
|
527
596
|
```
|
|
528
597
|
|
|
529
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
|
|
530
599
|
|
|
531
600
|
```diff
|
|
532
|
-
// manifest.json
|
|
533
601
|
{
|
|
534
|
-
-
|
|
535
|
-
+
|
|
536
|
-
|
|
537
|
-
|
|
602
|
+
- "manifest_version": 2,
|
|
603
|
+
+ "manifest_version": 3,
|
|
604
|
+
"name": "My Extension",
|
|
605
|
+
"version": "1.0.0"
|
|
538
606
|
}
|
|
539
607
|
```
|
|
540
608
|
|
|
@@ -577,11 +645,11 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
|
|
|
577
645
|
|
|
578
646
|
```diff
|
|
579
647
|
{
|
|
580
|
-
|
|
581
|
-
!
|
|
582
|
-
!
|
|
583
|
-
!
|
|
584
|
-
!
|
|
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'"
|
|
585
653
|
}
|
|
586
654
|
```
|
|
587
655
|
|
|
@@ -589,14 +657,14 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
|
|
|
589
657
|
|
|
590
658
|
```diff
|
|
591
659
|
{
|
|
592
|
-
|
|
593
|
-
-
|
|
594
|
-
-
|
|
595
|
-
|
|
596
|
-
-
|
|
597
|
-
+
|
|
598
|
-
|
|
599
|
-
+
|
|
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'" }
|
|
600
668
|
}
|
|
601
669
|
```
|
|
602
670
|
|
|
@@ -642,7 +710,7 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
|
|
|
642
710
|
! (function init() { chrome.runtime.onMessage.addListener(handler) })()
|
|
643
711
|
! setInterval(() => {}, 5000)
|
|
644
712
|
! const loc = window.location
|
|
645
|
-
! localStorage.setItem('key', '
|
|
713
|
+
! localStorage.setItem('key', 'value')
|
|
646
714
|
```
|
|
647
715
|
|
|
648
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
|
|
@@ -668,7 +736,7 @@ If your background context isn't a service worker.
|
|
|
668
736
|
|
|
669
737
|
#### See also
|
|
670
738
|
|
|
671
|
-
- [`
|
|
739
|
+
- [`require-mv3`](#require-mv3)
|
|
672
740
|
- [`no-remote-code`](#no-remote-code)
|
|
673
741
|
|
|
674
742
|
<div align="center">
|
|
@@ -677,11 +745,81 @@ If your background context isn't a service worker.
|
|
|
677
745
|
|
|
678
746
|
</div>
|
|
679
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
|
+
|
|
680
818
|
</details>
|
|
681
819
|
|
|
682
820
|
---
|
|
683
821
|
|
|
684
|
-
## <img
|
|
822
|
+
## <img width=30 align="top" src="https://api.iconify.design/boxicons/code-alt-filled.svg?color=%232b2b2a"> Shield embed
|
|
685
823
|
|
|
686
824
|
<a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
|
|
687
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>
|
|
@@ -689,7 +827,7 @@ If your background context isn't a service worker.
|
|
|
689
827
|
#### HTML
|
|
690
828
|
|
|
691
829
|
```html
|
|
692
|
-
<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">
|
|
693
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>
|
|
694
832
|
```
|
|
695
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
|
}
|