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.
Files changed (36) hide show
  1. package/README.md +592 -124
  2. package/package.json +5 -7
  3. package/src/data/i18n.js +8 -0
  4. package/src/data/permissions.js +1 -8
  5. package/src/data/regex.js +2 -1
  6. package/src/index.js +0 -4
  7. package/src/rules/css-spacing.js +367 -0
  8. package/src/rules/manifest-key-order.js +0 -1
  9. package/src/rules/manifest-spacing.js +0 -1
  10. package/src/rules/no-duplicate-manifest-keys.js +0 -1
  11. package/src/rules/no-global-keyframes.js +84 -0
  12. package/src/rules/no-host-page-selectors.js +106 -0
  13. package/src/rules/no-invalid-locales.js +89 -0
  14. package/src/rules/no-invalid-permissions.js +3 -5
  15. package/src/rules/no-missing-default-locale.js +39 -14
  16. package/src/rules/no-missing-i18n-keys.js +3 -5
  17. package/src/rules/no-remote-urls.js +102 -0
  18. package/src/rules/no-scrollbar-styling.js +73 -0
  19. package/src/rules/no-sensitive-permissions.js +1 -1
  20. package/src/rules/no-unrestricted-host-permissions.js +31 -16
  21. package/src/rules/no-unused-i18n-keys.js +0 -1
  22. package/src/rules/no-unused-permissions.js +13 -4
  23. package/src/rules/require-license-file.js +0 -1
  24. package/src/rules/require-mv3.js +1 -1
  25. package/src/rules/require-short-name-when-useful.js +0 -1
  26. package/src/rules/valid-license-file.js +2 -4
  27. package/src/rules/valid-manifest-icons.js +31 -3
  28. package/src/rules/valid-mv3-schema.js +1 -1
  29. package/src/rules/valid-url-fields.js +42 -37
  30. package/src/rules/valid-version-field.js +1 -1
  31. package/src/utils/array.js +1 -0
  32. package/src/utils/extension.js +13 -0
  33. package/src/utils/i18n.js +3 -0
  34. package/src/utils/init.js +13 -7
  35. package/src/utils/license.js +3 -2
  36. 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: false,
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) || permissions.isValid(permission, target)) return
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) || permissions.isValid(permission, target)) continue
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 readMessages(filePath) {
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: false,
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 the default locale'
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.filename ; if (!filename || !regex.filename.manifest.test(filename)) return {}
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
- for (const locale of readLocaleDirs(localesDir)) {
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.cssHTML.test(filename) && !regex.filename.manifest.test(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
+ }
@@ -7,7 +7,7 @@ export default {
7
7
  meta: {
8
8
  type: 'problem',
9
9
  docs: {
10
- description: 'Flag sensitive permissions that warrant security review',
10
+ description: 'Flag sensitive permissions',
11
11
  category: 'Possible Errors',
12
12
  recommended: true,
13
13
  security: true,
@@ -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: false,
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
- '*'(node) {
39
- const keyNode = node.name || node.key ; if (!keyNode) return
40
- const keyName = keyNode.value ?? keyNode.name
41
- if (!hostKeys.has(keyName)) return
42
- const arrNode = node.value ; if (arrNode?.type != 'Array') return
43
- const nodeElems = arrNode.elements || arrNode.items || []
44
- for (const elem of nodeElems) {
45
- const valueNode = elem.value || elem,
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
  }
@@ -10,7 +10,6 @@ export default {
10
10
  docs: {
11
11
  description: 'Flag i18n keys in `messages.json` that are never referenced',
12
12
  category: 'Stylistic Issues',
13
- recommended: false,
14
13
  manifest: true
15
14
  },
16
15
  schema: [{
@@ -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: false,
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
- if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
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