eslint-plugin-chrome-extensions 2.5.0 → 2.7.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 +592 -124
- package/package.json +5 -7
- package/src/data/i18n.js +8 -0
- package/src/data/permissions.js +1 -8
- package/src/data/regex.js +2 -1
- package/src/index.js +0 -4
- package/src/rules/css-spacing.js +367 -0
- package/src/rules/manifest-key-order.js +0 -1
- package/src/rules/manifest-spacing.js +0 -1
- package/src/rules/no-duplicate-manifest-keys.js +0 -1
- package/src/rules/no-global-keyframes.js +84 -0
- package/src/rules/no-host-page-selectors.js +106 -0
- package/src/rules/no-invalid-locales.js +89 -0
- package/src/rules/no-invalid-permissions.js +3 -5
- package/src/rules/no-missing-default-locale.js +39 -14
- package/src/rules/no-missing-i18n-keys.js +3 -5
- package/src/rules/no-remote-urls.js +102 -0
- package/src/rules/no-scrollbar-styling.js +73 -0
- package/src/rules/no-sensitive-permissions.js +1 -1
- package/src/rules/no-unrestricted-host-permissions.js +31 -16
- package/src/rules/no-unused-i18n-keys.js +0 -1
- package/src/rules/no-unused-permissions.js +13 -4
- package/src/rules/require-license-file.js +0 -1
- package/src/rules/require-mv3.js +1 -1
- package/src/rules/require-short-name-when-useful.js +0 -1
- package/src/rules/valid-license-file.js +2 -4
- package/src/rules/valid-manifest-icons.js +31 -3
- package/src/rules/valid-mv3-schema.js +1 -1
- package/src/rules/valid-url-fields.js +42 -37
- package/src/rules/valid-version-field.js +1 -1
- package/src/utils/array.js +1 -0
- package/src/utils/extension.js +13 -0
- package/src/utils/i18n.js +3 -0
- package/src/utils/init.js +13 -7
- package/src/utils/license.js +3 -2
- package/src/data/deprecations.js +0 -10
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
import regex from '../data/regex.js'
|
|
3
|
+
|
|
4
|
+
const re = { keyframeSelector: /^(?:from|to|[\d.]+%)$/i }
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Disallow host-page selectors (`html`, `body`, `:root`, `*`, bare element types) in content-script CSS',
|
|
11
|
+
category: 'Possible Errors',
|
|
12
|
+
css: true
|
|
13
|
+
},
|
|
14
|
+
defaultOptions: [{
|
|
15
|
+
allowedSelectors: [],
|
|
16
|
+
disallowUniversal: true,
|
|
17
|
+
disallowStructural: true,
|
|
18
|
+
disallowBareElements: true
|
|
19
|
+
}],
|
|
20
|
+
schema: [{
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
allowedSelectors: {
|
|
24
|
+
oneOf: [
|
|
25
|
+
{ type: 'string', description: 'A single selector' },
|
|
26
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of selectors' }
|
|
27
|
+
],
|
|
28
|
+
description: 'Selectors to explicitly allow (e.g. `body.dark`)'
|
|
29
|
+
},
|
|
30
|
+
disallowUniversal: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'Flag `*`'
|
|
33
|
+
},
|
|
34
|
+
disallowStructural: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
description: 'Flag `html`, `body`, `:root`'
|
|
37
|
+
},
|
|
38
|
+
disallowBareElements: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
description: 'Flag bare element types (`div`, `a`, etc.) lacking a class/id/attribute qualifier'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
additionalProperties: false
|
|
44
|
+
}],
|
|
45
|
+
messages: {
|
|
46
|
+
universalSelector: 'Universal selector `*` targets every element on the host page. Scope with a namespaced class or use `:host`.',
|
|
47
|
+
structuralSelector: 'Selector `{{selector}}` targets a host-page structural element. Use `:host` or a namespaced root instead.',
|
|
48
|
+
elementSelector: 'Selector `{{selector}}` targets a bare element type on the host page. Scope with a namespaced class or wrap in `:host()`.'
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
create(context) {
|
|
53
|
+
const opts = context.options[0] || {},
|
|
54
|
+
allowedSelectors = new Set(array.to(opts.allowedSelectors)),
|
|
55
|
+
checkUniversal = opts.disallowUniversal !== false,
|
|
56
|
+
checkStructural = opts.disallowStructural !== false,
|
|
57
|
+
checkBareElements = opts.disallowBareElements !== false
|
|
58
|
+
function checkSelector(selector) {
|
|
59
|
+
if (!selector) return
|
|
60
|
+
const text = getSelectorText(selector).trim()
|
|
61
|
+
if (re.keyframeSelector.test(text) || allowedSelectors.has(text)) return
|
|
62
|
+
const simples = []
|
|
63
|
+
for (const child of selector.children ?? []) {
|
|
64
|
+
if (plainType(child) == 'Combinator') break
|
|
65
|
+
simples.push(child)
|
|
66
|
+
}
|
|
67
|
+
let hasQualifier = false,
|
|
68
|
+
isUniversal = false,
|
|
69
|
+
isRoot = false,
|
|
70
|
+
elementTypes = []
|
|
71
|
+
for (const simple of simples) {
|
|
72
|
+
const type = plainType(simple)
|
|
73
|
+
if (type == 'ClassSelector' || type == 'IdSelector' || type == 'AttributeSelector') hasQualifier = true
|
|
74
|
+
else if (type == 'PseudoClassSelector' && (simple.name ?? '').toLowerCase() == 'root') isRoot = true
|
|
75
|
+
else if (type == 'TypeSelector') {
|
|
76
|
+
const name = (simple.name ?? '').toLowerCase()
|
|
77
|
+
if (name == '*') isUniversal = true
|
|
78
|
+
else if (name) elementTypes.push(name)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (isUniversal && checkUniversal)
|
|
82
|
+
return context.report({ node: selector, messageId: 'universalSelector' })
|
|
83
|
+
if (isRoot && checkStructural)
|
|
84
|
+
return context.report({ node: selector, messageId: 'structuralSelector', data: { selector: text }})
|
|
85
|
+
for (const type of elementTypes) {
|
|
86
|
+
if (type == 'html' || type == 'body') {
|
|
87
|
+
if (checkStructural)
|
|
88
|
+
return context.report({ node: selector, messageId: 'structuralSelector', data: { selector: text }})
|
|
89
|
+
} else if (checkBareElements && !hasQualifier) {
|
|
90
|
+
return context.report({ node: selector, messageId: 'elementSelector', data: { selector: text }})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function getSelectorText(selector) {
|
|
95
|
+
const range = selector.range
|
|
96
|
+
if (range && range.length == 2) return context.sourceCode.text.slice(range[0], range[1])
|
|
97
|
+
return context.sourceCode.getText(selector)
|
|
98
|
+
}
|
|
99
|
+
function handleRule(node) {
|
|
100
|
+
for (const selector of node.prelude?.children ?? [])
|
|
101
|
+
checkSelector(selector)
|
|
102
|
+
}
|
|
103
|
+
function plainType(node) { return node?.type?.replace(regex.prefix.css, '') ?? '' }
|
|
104
|
+
return { Rule: handleRule, CSSRule: handleRule }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
|
|
2
|
+
|
|
3
|
+
import extension from '../utils/extension.js'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import i18nUtils from '../utils/i18n.js'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
import regex from '../data/regex.js'
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
meta: {
|
|
12
|
+
type: 'problem',
|
|
13
|
+
docs: {
|
|
14
|
+
description: 'Disallow invalid Chrome locale codes in `_locales/` folders and `default_locale`',
|
|
15
|
+
category: 'Possible Errors',
|
|
16
|
+
recommended: true,
|
|
17
|
+
manifest: true
|
|
18
|
+
},
|
|
19
|
+
schema: [{
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
allowedLocales: {
|
|
23
|
+
oneOf: [
|
|
24
|
+
{ type: 'string', description: 'Single locale code to allow' },
|
|
25
|
+
{
|
|
26
|
+
type: 'array',
|
|
27
|
+
items: { type: 'string', description: 'Locale code to allow' },
|
|
28
|
+
description: 'List of locale codes to allow'
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
description: 'Additional locale codes to allow (e.g. for Chromium forks)'
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
additionalProperties: false
|
|
35
|
+
}],
|
|
36
|
+
defaultOptions: [{ allowedLocales: [] }],
|
|
37
|
+
messages: {
|
|
38
|
+
invalidLocale: '`{{value}}` is not a Chrome-supported locale code.'
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
create(context) {
|
|
43
|
+
const filename = extension.file.getName(context),
|
|
44
|
+
isManifest = regex.filename.manifest.test(filename),
|
|
45
|
+
isScript = regex.filename.script.test(filename)
|
|
46
|
+
if (!isManifest && !isScript) return {}
|
|
47
|
+
const opts = context.options[0] || {},
|
|
48
|
+
allowed = new Set(Array.isArray(opts.allowedLocales) ? opts.allowedLocales
|
|
49
|
+
: opts.allowedLocales ? [opts.allowedLocales] : []),
|
|
50
|
+
invalidFolders = [],
|
|
51
|
+
root = extension.findRoot(context)
|
|
52
|
+
if (root) {
|
|
53
|
+
const localesDir = path.join(root, '_locales')
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of fs.readdirSync(localesDir, { withFileTypes: true }))
|
|
56
|
+
if (entry.isDirectory() && isInvalid(entry.name)) invalidFolders.push(entry.name)
|
|
57
|
+
invalidFolders.sort()
|
|
58
|
+
} catch {} // eslint-disable-line no-empty
|
|
59
|
+
}
|
|
60
|
+
const loc = { line: 1, column: 1 }
|
|
61
|
+
if (!isManifest) {
|
|
62
|
+
const manifest = extension.manifest.read(context)
|
|
63
|
+
if (manifest) {
|
|
64
|
+
const defaultLocale = manifest.default_locale
|
|
65
|
+
if (isInvalid(defaultLocale))
|
|
66
|
+
context.report({ loc, messageId: 'invalidLocale', data: { value: defaultLocale }})
|
|
67
|
+
}
|
|
68
|
+
for (const name of invalidFolders)
|
|
69
|
+
context.report({ loc, messageId: 'invalidLocale', data: { value: name }})
|
|
70
|
+
return {}
|
|
71
|
+
}
|
|
72
|
+
function isInvalid(locale) {
|
|
73
|
+
return typeof locale == 'string' && !i18nUtils.localeIsValid(locale, allowed) }
|
|
74
|
+
return {
|
|
75
|
+
'*'(node) {
|
|
76
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
77
|
+
const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'default_locale') return
|
|
78
|
+
const valueNode = node.value ; if (valueNode?.type != 'String') return
|
|
79
|
+
if (isInvalid(valueNode.value))
|
|
80
|
+
context.report({ node: valueNode, messageId: 'invalidLocale',
|
|
81
|
+
data: { value: valueNode.value }})
|
|
82
|
+
},
|
|
83
|
+
'Document:exit'() {
|
|
84
|
+
for (const name of invalidFolders)
|
|
85
|
+
context.report({ loc, messageId: 'invalidLocale', data: { value: name }})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import extension from '../utils/extension.js'
|
|
2
|
-
|
|
3
|
-
import permissions from '../data/permissions.js'
|
|
4
2
|
import regex from '../data/regex.js'
|
|
5
3
|
|
|
6
4
|
export default {
|
|
@@ -9,7 +7,7 @@ export default {
|
|
|
9
7
|
docs: {
|
|
10
8
|
description: 'Disallow invalid or unsupported manifest permission names',
|
|
11
9
|
category: 'Possible Errors',
|
|
12
|
-
recommended:
|
|
10
|
+
recommended: true,
|
|
13
11
|
manifest: true
|
|
14
12
|
},
|
|
15
13
|
defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],
|
|
@@ -46,7 +44,7 @@ export default {
|
|
|
46
44
|
: opts.allowedPermissions ? [opts.allowedPermissions] : [])
|
|
47
45
|
function checkPermission(node, permission) {
|
|
48
46
|
if (typeof permission != 'string') return
|
|
49
|
-
if (allowed.has(permission) ||
|
|
47
|
+
if (allowed.has(permission) || extension.manifest.permission.isValid(permission, target)) return
|
|
50
48
|
context.report({ node, messageId: 'invalidPermission', data: { permission, target }})
|
|
51
49
|
}
|
|
52
50
|
if (regex.filename.manifest.test(extension.file.getName(context))) // lint it directly
|
|
@@ -67,7 +65,7 @@ export default {
|
|
|
67
65
|
return {
|
|
68
66
|
'Program:exit'() {
|
|
69
67
|
for (const permission of allPermissions) {
|
|
70
|
-
if (allowed.has(permission) ||
|
|
68
|
+
if (allowed.has(permission) || extension.manifest.permission.isValid(permission, target)) continue
|
|
71
69
|
context.report({ loc: { line: 1, column: 0 },
|
|
72
70
|
messageId: 'invalidPermission', data: { permission, target }})
|
|
73
71
|
}
|
|
@@ -7,14 +7,7 @@ import path from 'node:path'
|
|
|
7
7
|
|
|
8
8
|
import regex from '../data/regex.js'
|
|
9
9
|
|
|
10
|
-
function
|
|
11
|
-
try {
|
|
12
|
-
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
13
|
-
return (data && typeof data == 'object' && !Array.isArray(data)) ? data : null
|
|
14
|
-
} catch { return null }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function readLocaleDirs(localesDir) {
|
|
10
|
+
function readLocaleDirs(localesDir) {
|
|
18
11
|
try {
|
|
19
12
|
return fs.readdirSync(localesDir, { withFileTypes: true })
|
|
20
13
|
.filter(entry => entry.isDirectory())
|
|
@@ -22,13 +15,20 @@ function readLocaleDirs(localesDir) {
|
|
|
22
15
|
} catch { return [] }
|
|
23
16
|
}
|
|
24
17
|
|
|
18
|
+
function readMessages(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
21
|
+
return (data && typeof data == 'object' && !Array.isArray(data)) ? data : null
|
|
22
|
+
} catch { return null }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
25
|
export default {
|
|
26
26
|
meta: {
|
|
27
27
|
type: 'problem',
|
|
28
28
|
docs: {
|
|
29
29
|
description: 'Ensure `default_locale` is valid and all `_locales` keys exist in it',
|
|
30
30
|
category: 'Possible Errors',
|
|
31
|
-
recommended:
|
|
31
|
+
recommended: true,
|
|
32
32
|
manifest: true
|
|
33
33
|
},
|
|
34
34
|
schema: [{
|
|
@@ -36,23 +36,29 @@ export default {
|
|
|
36
36
|
properties: {
|
|
37
37
|
ignoreKeys: {
|
|
38
38
|
type: 'array', items: { type: 'string' },
|
|
39
|
-
description: 'Keys to skip when checking
|
|
39
|
+
description: 'Keys to skip when checking locales'
|
|
40
|
+
},
|
|
41
|
+
checkAllLocales: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
description: 'Also flag keys missing from any locale, not just the default'
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
additionalProperties: false
|
|
43
47
|
}],
|
|
44
|
-
defaultOptions: [{ ignoreKeys: [] }],
|
|
48
|
+
defaultOptions: [{ ignoreKeys: [], checkAllLocales: false }],
|
|
45
49
|
messages: {
|
|
46
50
|
missingDefaultLocale: '`_locales/` exists but `default_locale` is not declared.',
|
|
47
51
|
unexpectedDefaultLocale: '`default_locale` is declared but no `_locales/` directory exists.',
|
|
48
52
|
missingDefaultLocaleFile: '`_locales/{{locale}}/messages.json` does not exist.',
|
|
49
53
|
missingKeyInDefault: '`{{key}}` exists in `_locales/{{locale}}/messages.json`, '
|
|
50
|
-
+ 'but not in default locale `{{defaultLocale}}`.'
|
|
54
|
+
+ 'but not in default locale `{{defaultLocale}}`.',
|
|
55
|
+
missingKeyInLocale: '`{{key}}` is missing from `_locales/{{locale}}/messages.json`.'
|
|
51
56
|
}
|
|
52
57
|
},
|
|
53
58
|
|
|
54
59
|
create(context) {
|
|
55
|
-
const filename = context
|
|
60
|
+
const filename = extension.file.getName(context) ; if (!filename) return {}
|
|
61
|
+
if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
|
|
56
62
|
const manifest = extension.manifest.read(context) ; if (!manifest) return {}
|
|
57
63
|
const root = extension.findRoot(context) ; if (!root) return {}
|
|
58
64
|
const localesDir = path.join(root, '_locales'),
|
|
@@ -60,6 +66,7 @@ export default {
|
|
|
60
66
|
defaultLocale = manifest.default_locale,
|
|
61
67
|
opts = context.options[0] || {},
|
|
62
68
|
ignored = new Set(opts.ignoreKeys || []),
|
|
69
|
+
checkAllLocales = opts.checkAllLocales ?? false,
|
|
63
70
|
loc = { line: 1, column: 1 }
|
|
64
71
|
if (hasLocales && typeof defaultLocale != 'string') {
|
|
65
72
|
context.report({ loc, messageId: 'missingDefaultLocale' })
|
|
@@ -75,7 +82,25 @@ export default {
|
|
|
75
82
|
context.report({ loc, messageId: 'missingDefaultLocaleFile', data: { locale: defaultLocale }})
|
|
76
83
|
return {}
|
|
77
84
|
}
|
|
78
|
-
|
|
85
|
+
const localeDirs = readLocaleDirs(localesDir)
|
|
86
|
+
if (checkAllLocales) {
|
|
87
|
+
const allMsgs = new Map([[defaultLocale, defaultMsgs]])
|
|
88
|
+
for (const locale of localeDirs) {
|
|
89
|
+
if (locale == defaultLocale) continue
|
|
90
|
+
const msgs = readMessages(path.join(localesDir, locale, 'messages.json'))
|
|
91
|
+
if (msgs) allMsgs.set(locale, msgs)
|
|
92
|
+
}
|
|
93
|
+
const union = new Set()
|
|
94
|
+
for (const msgs of allMsgs.values())
|
|
95
|
+
for (const key of Object.keys(msgs))
|
|
96
|
+
if (!i18n.isPredefined(key) && !ignored.has(key)) union.add(key)
|
|
97
|
+
for (const [locale, msgs] of allMsgs)
|
|
98
|
+
for (const key of union)
|
|
99
|
+
if (!(key in msgs))
|
|
100
|
+
context.report({ loc, messageId: 'missingKeyInLocale', data: { key, locale }})
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
for (const locale of localeDirs) {
|
|
79
104
|
if (locale == defaultLocale) continue
|
|
80
105
|
const msgs = readMessages(path.join(localesDir, locale, 'messages.json')) ; if (!msgs) continue
|
|
81
106
|
for (const key of Object.keys(msgs)) {
|
|
@@ -13,9 +13,7 @@ export default {
|
|
|
13
13
|
category: 'Possible Errors',
|
|
14
14
|
recommended: true,
|
|
15
15
|
manifest: true,
|
|
16
|
-
css: true
|
|
17
|
-
html: true,
|
|
18
|
-
json: true
|
|
16
|
+
css: true
|
|
19
17
|
},
|
|
20
18
|
schema: [{
|
|
21
19
|
type: 'object',
|
|
@@ -57,7 +55,7 @@ export default {
|
|
|
57
55
|
context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
|
-
if (!regex.filename.
|
|
58
|
+
if (!regex.filename.css.test(filename) && !regex.filename.manifest.test(filename))
|
|
61
59
|
return {}
|
|
62
60
|
const seenKeys = new Set()
|
|
63
61
|
function scanPlaceholders() {
|
|
@@ -69,6 +67,6 @@ export default {
|
|
|
69
67
|
context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
|
-
return { 'Document': scanPlaceholders, 'Program': scanPlaceholders }
|
|
70
|
+
return { 'Document': scanPlaceholders, 'Program': scanPlaceholders, 'StyleSheet': scanPlaceholders }
|
|
73
71
|
}
|
|
74
72
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
|
|
3
|
+
const re = { urlScheme: /^(?:([a-z][a-z0-9+.-]*):|\/\/)/i }
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow remote resources (`@import`, `url()`, `@namespace`) in extension CSS',
|
|
10
|
+
category: 'Possible Errors',
|
|
11
|
+
css: true
|
|
12
|
+
},
|
|
13
|
+
defaultOptions: [{
|
|
14
|
+
allowedSchemes: ['blob', 'chrome-extension', 'data'],
|
|
15
|
+
patterns: ['at-import', 'url']
|
|
16
|
+
}],
|
|
17
|
+
schema: [{
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
allowedSchemes: {
|
|
21
|
+
oneOf: [
|
|
22
|
+
{ type: 'string', description: 'A single URL scheme' },
|
|
23
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of URL schemes' }
|
|
24
|
+
],
|
|
25
|
+
description: 'URL schemes to allow (e.g. `chrome-extension`, `data`, `blob`)'
|
|
26
|
+
},
|
|
27
|
+
patterns: {
|
|
28
|
+
oneOf: [
|
|
29
|
+
{ enum: ['at-import', 'url', 'namespace'], description: 'A single detection path' },
|
|
30
|
+
{ type: 'array', items: { enum: ['at-import', 'url', 'namespace'] }, description: 'List of detection paths' }
|
|
31
|
+
],
|
|
32
|
+
description: 'Detection paths to enable'
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
additionalProperties: false
|
|
36
|
+
}],
|
|
37
|
+
messages: {
|
|
38
|
+
remoteImport: 'Remote `@import` is disallowed. Bundle the stylesheet or use `chrome.runtime.getURL()`.',
|
|
39
|
+
remoteURL: 'Remote `url()` is disallowed. Bundle the asset or use `chrome.runtime.getURL()`.',
|
|
40
|
+
remoteNamespace: 'Remote `@namespace` is disallowed. Reference a bundled stylesheet.'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
create(context) {
|
|
45
|
+
const opts = context.options[0] || {},
|
|
46
|
+
allowedSchemes = new Set(array.to(opts.allowedSchemes ?? ['blob', 'chrome-extension', 'data'])),
|
|
47
|
+
patterns = new Set(array.to(opts.patterns ?? ['at-import', 'url'])),
|
|
48
|
+
handledURLs = new WeakSet()
|
|
49
|
+
function getPreludeURLnode(prelude) {
|
|
50
|
+
if (!prelude) return null
|
|
51
|
+
if (['Url', 'CSSUrl', 'String', 'CSSString'].includes(prelude.type))
|
|
52
|
+
return prelude
|
|
53
|
+
for (const child of prelude.children ?? [])
|
|
54
|
+
if (['Url', 'CSSUrl', 'String', 'CSSString'].includes(child.type))
|
|
55
|
+
return child
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
function getURLstr(node) {
|
|
59
|
+
if (!node) return null
|
|
60
|
+
if (typeof node == 'string') return node
|
|
61
|
+
if (typeof node.url == 'string') return node.url
|
|
62
|
+
if (typeof node.value == 'string') return node.value
|
|
63
|
+
if (node.value && typeof node.value == 'object' && typeof node.value.value == 'string')
|
|
64
|
+
return node.value.value
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
function handleAtrule(node) {
|
|
68
|
+
if (!node.prelude) return
|
|
69
|
+
if (node.name == 'import' && patterns.has('at-import')) {
|
|
70
|
+
const urlNode = getPreludeURLnode(node.prelude)
|
|
71
|
+
if (urlNode && isRemote(getURLstr(urlNode))) {
|
|
72
|
+
handledURLs.add(urlNode)
|
|
73
|
+
context.report({ node, messageId: 'remoteImport' })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (node.name == 'namespace' && patterns.has('namespace')) {
|
|
77
|
+
const urlNode = getPreludeURLnode(node.prelude)
|
|
78
|
+
if (urlNode && isRemote(getURLstr(urlNode))) {
|
|
79
|
+
handledURLs.add(urlNode)
|
|
80
|
+
context.report({ node, messageId: 'remoteNamespace' })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function handleURL(node) {
|
|
85
|
+
if (!patterns.has('url')) return
|
|
86
|
+
if (handledURLs.has(node)) return
|
|
87
|
+
if (node.parent?.type == 'AtrulePrelude' || node.parent?.type == 'CSSAtRulePrelude') return
|
|
88
|
+
if (isRemote(getURLstr(node)))
|
|
89
|
+
context.report({ node, messageId: 'remoteURL' })
|
|
90
|
+
}
|
|
91
|
+
function isRemote(str) {
|
|
92
|
+
if (typeof str != 'string') return false
|
|
93
|
+
const match = str.match(re.urlScheme)
|
|
94
|
+
if (!match) return false
|
|
95
|
+
if (match[0] == '//') return true
|
|
96
|
+
const scheme = (match[1] || '').toLowerCase()
|
|
97
|
+
if (['http', 'https', 'ftp'].includes(scheme)) return true
|
|
98
|
+
return !allowedSchemes.has(scheme)
|
|
99
|
+
}
|
|
100
|
+
return { Atrule: handleAtrule, CSSAtRule: handleAtrule, Url: handleURL, CSSUrl: handleURL }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
import regex from '../data/regex.js'
|
|
3
|
+
|
|
4
|
+
const re = {
|
|
5
|
+
scrollbarPseudo: /^(?:-webkit-)?scrollbar(?:-[\w-]+)?$/i } // eslint-disable-line security/detect-unsafe-regex
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
meta: {
|
|
9
|
+
type: 'problem',
|
|
10
|
+
docs: {
|
|
11
|
+
description: 'Disallow scrollbar pseudo-elements (e.g. `::-webkit-scrollbar`) '
|
|
12
|
+
+ 'on unscoped selectors in content-script CSS',
|
|
13
|
+
category: 'Possible Errors',
|
|
14
|
+
css: true
|
|
15
|
+
},
|
|
16
|
+
defaultOptions: [{ allowedSelectors: [] }],
|
|
17
|
+
schema: [{
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
allowedSelectors: {
|
|
21
|
+
oneOf: [
|
|
22
|
+
{ type: 'string', description: 'A single selector' },
|
|
23
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of selectors' }
|
|
24
|
+
],
|
|
25
|
+
description: 'Selectors to explicitly allow (e.g. `body::-webkit-scrollbar`)'
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
additionalProperties: false
|
|
29
|
+
}],
|
|
30
|
+
messages: {
|
|
31
|
+
noScrollbarStyling: 'Scrollbar pseudo-element on `{{selector}}` styles the host page\'s scrollbar. '
|
|
32
|
+
+ 'Scope it to a namespaced class (e.g. `.myext-panel::...`) or use `allowedSelectors`.'
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
create(context) {
|
|
37
|
+
const opts = context.options[0] || {},
|
|
38
|
+
allowedSelectors = new Set(array.to(opts.allowedSelectors))
|
|
39
|
+
function checkSelector(selector) {
|
|
40
|
+
if (!selector) return
|
|
41
|
+
const text = getSelectorText(selector).trim() ; if (allowedSelectors.has(text)) return
|
|
42
|
+
let hasScrollbarPseudo = false,
|
|
43
|
+
hasQualifier = false,
|
|
44
|
+
pastFirstCompound = false
|
|
45
|
+
for (const simple of selector.children ?? []) {
|
|
46
|
+
const type = plainType(simple)
|
|
47
|
+
if (type == 'Combinator') { pastFirstCompound = true; continue }
|
|
48
|
+
if (type == 'PseudoElementSelector' && re.scrollbarPseudo.test(simple.name ?? '')) {
|
|
49
|
+
hasScrollbarPseudo = true
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (pastFirstCompound) continue
|
|
53
|
+
if (type == 'ClassSelector' || type == 'IdSelector' || type == 'AttributeSelector')
|
|
54
|
+
hasQualifier = true
|
|
55
|
+
else if (type == 'PseudoClassSelector' && (simple.name ?? '').toLowerCase().startsWith('host'))
|
|
56
|
+
hasQualifier = true
|
|
57
|
+
}
|
|
58
|
+
if (hasScrollbarPseudo && !hasQualifier)
|
|
59
|
+
context.report({ node: selector, messageId: 'noScrollbarStyling', data: { selector: text }})
|
|
60
|
+
}
|
|
61
|
+
function getSelectorText(selector) {
|
|
62
|
+
const range = selector.range
|
|
63
|
+
if (range && range.length == 2) return context.sourceCode.text.slice(range[0], range[1])
|
|
64
|
+
return context.sourceCode.getText(selector)
|
|
65
|
+
}
|
|
66
|
+
function handleRule(node) {
|
|
67
|
+
for (const selector of node.prelude?.children ?? [])
|
|
68
|
+
checkSelector(selector)
|
|
69
|
+
}
|
|
70
|
+
function plainType(node) { return node?.type?.replace(regex.prefix.css, '') ?? '' }
|
|
71
|
+
return { Rule: handleRule, CSSRule: handleRule }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -10,7 +10,8 @@ export default {
|
|
|
10
10
|
docs: {
|
|
11
11
|
description: 'Disallow broad host permissions that grant access to every site',
|
|
12
12
|
category: 'Possible Errors',
|
|
13
|
-
recommended:
|
|
13
|
+
recommended: true,
|
|
14
|
+
security: true,
|
|
14
15
|
manifest: true
|
|
15
16
|
},
|
|
16
17
|
defaultOptions: [{ allowedPatterns: [] }],
|
|
@@ -32,23 +33,37 @@ export default {
|
|
|
32
33
|
},
|
|
33
34
|
|
|
34
35
|
create(context) {
|
|
35
|
-
if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
|
|
36
36
|
const allowedPatterns = new Set(context.options[0]?.allowedPatterns || [])
|
|
37
|
+
function isBroad(pattern) {
|
|
38
|
+
return typeof pattern == 'string'
|
|
39
|
+
&& !allowedPatterns.has(pattern)
|
|
40
|
+
&& re.broadHost.test(pattern)
|
|
41
|
+
}
|
|
42
|
+
if (regex.filename.manifest.test(extension.file.getName(context))) // lint it directly
|
|
43
|
+
return {
|
|
44
|
+
'*'(node) {
|
|
45
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
46
|
+
const keyName = keyNode.value ?? keyNode.name ; if (!hostKeys.has(keyName)) return
|
|
47
|
+
const arrNode = node.value ; if (arrNode?.type != 'Array') return
|
|
48
|
+
const nodeElems = arrNode.elements || arrNode.items || []
|
|
49
|
+
for (const elem of nodeElems) {
|
|
50
|
+
const valueNode = elem.value || elem,
|
|
51
|
+
pattern = valueNode?.value
|
|
52
|
+
if (!isBroad(pattern)) continue
|
|
53
|
+
context.report({ node: valueNode, messageId: 'unrestrictedHost', data: { pattern }})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const manifest = extension.manifest.read(context) ; if (!manifest) return {}
|
|
37
58
|
return {
|
|
38
|
-
'
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
pattern = valueNode?.value
|
|
47
|
-
if (typeof pattern != 'string'
|
|
48
|
-
|| allowedPatterns.has(pattern)
|
|
49
|
-
|| !re.broadHost.test(pattern)
|
|
50
|
-
) continue
|
|
51
|
-
context.report({ node: valueNode, messageId: 'unrestrictedHost', data: { pattern }})
|
|
59
|
+
'Program:exit'() {
|
|
60
|
+
for (const key of hostKeys) {
|
|
61
|
+
const patterns = manifest[key] // eslint-disable-line security/detect-object-injection
|
|
62
|
+
if (!Array.isArray(patterns)) continue
|
|
63
|
+
for (const pattern of patterns)
|
|
64
|
+
if (isBroad(pattern))
|
|
65
|
+
context.report({ loc: { line: 1, column: 1 },
|
|
66
|
+
messageId: 'unrestrictedHost', data: { pattern }})
|
|
52
67
|
}
|
|
53
68
|
}
|
|
54
69
|
}
|
|
@@ -33,7 +33,7 @@ export default {
|
|
|
33
33
|
docs: {
|
|
34
34
|
description: 'Flag manifest permissions whose APIs are never used',
|
|
35
35
|
category: 'Possible Errors',
|
|
36
|
-
recommended:
|
|
36
|
+
recommended: true,
|
|
37
37
|
manifest: true
|
|
38
38
|
},
|
|
39
39
|
schema: [],
|
|
@@ -43,11 +43,11 @@ export default {
|
|
|
43
43
|
},
|
|
44
44
|
|
|
45
45
|
create(context) {
|
|
46
|
-
|
|
46
|
+
const filename = extension.file.getName(context)
|
|
47
|
+
if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
|
|
47
48
|
const manifest = extension.manifest.read(context) ; if (!manifest) return {}
|
|
48
49
|
const root = extension.findRoot(context) ; if (!root) return {}
|
|
49
|
-
const usedAPIs = collectUsedAPIs(manifest, root)
|
|
50
|
-
permissionNodes = new Map()
|
|
50
|
+
const usedAPIs = collectUsedAPIs(manifest, root)
|
|
51
51
|
function isUsed(permission) {
|
|
52
52
|
let knownPerm = false
|
|
53
53
|
for (const [, perm] of apis.permissionMap)
|
|
@@ -57,6 +57,15 @@ export default {
|
|
|
57
57
|
if (apis.findPermission(api) == permission) return true
|
|
58
58
|
return false
|
|
59
59
|
}
|
|
60
|
+
if (!regex.filename.manifest.test(filename)) {
|
|
61
|
+
const loc = { line: 1, column: 1 },
|
|
62
|
+
declared = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
|
|
63
|
+
for (const permission of declared)
|
|
64
|
+
if (typeof permission == 'string' && !isUsed(permission))
|
|
65
|
+
context.report({ loc, messageId: 'unusedPermission', data: { permission }})
|
|
66
|
+
return {}
|
|
67
|
+
}
|
|
68
|
+
const permissionNodes = new Map()
|
|
60
69
|
return {
|
|
61
70
|
'*'(node) {
|
|
62
71
|
const keyNode = node.name || node.key ; if (!keyNode) return
|