eslint-plugin-chrome-extensions 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ import { getMemberPath, isContentScriptFile, readManifest } from '../utils.js'
2
+
3
+ const re = { leadingSlash: /^\//, regexSpecial: /[.+?^${}()|[\]\\]/g }
4
+
5
+ function collectWarPatterns(manifest) {
6
+ const war = manifest.web_accessible_resources
7
+ if (!Array.isArray(war)) return []
8
+ const patterns = []
9
+ for (const entry of war) {
10
+ if (typeof entry == 'string') patterns.push(entry) // MV2 flat
11
+ else if (Array.isArray(entry?.resources))
12
+ for (const res of entry.resources) patterns.push(res) // MV3 object
13
+ }
14
+ return patterns
15
+ }
16
+
17
+ function patternToRegex(pattern) {
18
+ const escaped = pattern.replace(re.regexSpecial, '\\$&').replace(/\*/g, '.*')
19
+ return new RegExp(`^${escaped}$`) // eslint-disable-line security/detect-non-literal-regexp
20
+ }
21
+
22
+ export default {
23
+ meta: {
24
+ type: 'problem',
25
+ docs: {
26
+ description: 'Require content-script getURL() paths to be listed in `web_accessible_resources`',
27
+ category: 'Possible Errors',
28
+ recommended: true
29
+ },
30
+ schema: [],
31
+ messages: {
32
+ missingWar: '`{{path}}` is not in `web_accessible_resources`.'
33
+ }
34
+ },
35
+
36
+ create(context) {
37
+ const manifest = readManifest(context) ; if (!manifest) return {}
38
+ if (!isContentScriptFile(context, manifest)) return {}
39
+ const regexes = collectWarPatterns(manifest).map(patternToRegex)
40
+ return {
41
+ CallExpression(node) {
42
+ if (node.callee.type != 'MemberExpression') return
43
+ if (getMemberPath(node.callee) != 'chrome.runtime.getURL') return
44
+ const arg = node.arguments[0] ; if (arg?.type != 'Literal' || typeof arg.value != 'string') return
45
+ const resourcePath = arg.value.replace(re.leadingSlash, '')
46
+ for (const regex of regexes) if (regex.test(resourcePath)) return
47
+ context.report({ node: arg, messageId: 'missingWar', data: { path: arg.value }})
48
+ }
49
+ }
50
+ }
51
+ }
@@ -1,4 +1,5 @@
1
1
  import { remoteCodePatterns } from '../data/manifest.js'
2
+
2
3
  import { getMemberPath } from '../utils.js'
3
4
 
4
5
  const re = { url: /^https?:\/\// }
@@ -7,9 +8,10 @@ export default {
7
8
  meta: {
8
9
  type: 'problem',
9
10
  docs: {
10
- description: 'Disallow remote or dynamically-evaluated code, per the MV3 CSP',
11
+ description: 'Disallow remote or dynamically-evaluated code, per MV3\'s CSP',
11
12
  category: 'Possible Errors',
12
- recommended: true
13
+ recommended: true,
14
+ security: true
13
15
  },
14
16
  defaultOptions: [{
15
17
  patterns: ['eval', 'new-function', 'string-timer',
@@ -26,19 +28,19 @@ export default {
26
28
  },
27
29
  checkExecuteScriptClosures: {
28
30
  type: 'boolean',
29
- description: 'Flag chrome.scripting.executeScript calls whose func captures outer scope'
31
+ description: 'Flag `chrome.scripting.executeScript` calls whose func captures outer scope'
30
32
  }
31
33
  },
32
34
  additionalProperties: false
33
35
  }],
34
36
  messages: {
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.',
37
+ eval: '`eval()` is forbidden under MV3\'s CSP. Use static code instead.',
38
+ newFunction: '`new Function()` is forbidden under MV3\'s CSP. Use a real function instead.',
39
+ stringTimer: 'String-argument `{{fn}}` is forbidden under MV3\'s CSP. Pass a function reference instead.',
40
+ dynamicImport: 'Remote dynamic `import()` is forbidden under MV3\'s CSP. Use a relative path instead.',
41
+ remoteScriptSrc: 'Injecting remote `<script src>` is forbidden under MV3\'s CSP. Use `chrome.runtime.getURL()` for bundled scripts instead.',
40
42
  executeScriptURL: '`chrome.scripting.executeScript` must use local `files`, not a remote URL.',
41
- executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization - pass them via `args`.'
43
+ executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization. Pass them via `args` instead.'
42
44
  }
43
45
  },
44
46
 
@@ -0,0 +1,54 @@
1
+ import { getFilename } from '../utils.js'
2
+
3
+ const re = { broadHost: /^(?:<all_urls>|\*:\/\/\*\/\*|\w+:\/\/\*\/\*)$/, manifestFilename: /manifest\.json$/ },
4
+ hostKeys = new Set(['host_permissions', 'optional_host_permissions', 'optional_permissions', 'permissions'])
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'problem',
9
+ docs: {
10
+ description: 'Disallow broad host permissions that grant access to every site',
11
+ category: 'Possible Errors',
12
+ recommended: false,
13
+ manifest: true
14
+ },
15
+ defaultOptions: [{ allowedPatterns: [] }],
16
+ schema: [{
17
+ type: 'object',
18
+ properties: {
19
+ allowedPatterns: {
20
+ type: 'array',
21
+ items: { type: 'string' },
22
+ description: 'Host patterns to explicitly allow despite being broad'
23
+ }
24
+ },
25
+ additionalProperties: false
26
+ }],
27
+ messages: {
28
+ unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. Narrow to specific origins or allow explicitly instead.'
29
+ }
30
+ },
31
+
32
+ create(context) {
33
+ if (!re.manifestFilename.test(getFilename(context))) return {}
34
+ const allowed = new Set(context.options[0]?.allowedPatterns || [])
35
+ return {
36
+ '*'(node) {
37
+ const keyNode = node.name || node.key ; if (!keyNode) return
38
+ const keyName = keyNode.value ?? keyNode.name
39
+ if (!hostKeys.has(keyName)) return
40
+ const arrNode = node.value ; if (arrNode?.type != 'Array') return
41
+ const elements = arrNode.elements || arrNode.items || []
42
+ for (const el of elements) {
43
+ const valueNode = el.value || el,
44
+ pattern = valueNode?.value
45
+ if (typeof pattern != 'string'
46
+ || allowed.has(pattern)
47
+ || !re.broadHost.test(pattern)
48
+ ) continue
49
+ context.report({ node: valueNode, messageId: 'unrestrictedHost', data: { pattern }})
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
@@ -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: true,
36
+ recommended: false,
37
37
  manifest: true
38
38
  },
39
39
  schema: [],
@@ -8,12 +8,12 @@ export default {
8
8
  docs: {
9
9
  description: 'Require manifest_version 3',
10
10
  category: 'Possible Errors',
11
- recommended: true,
11
+ recommended: false,
12
12
  manifest: true
13
13
  },
14
14
  schema: [],
15
15
  messages: {
16
- notMV3: 'manifest.json must declare `"manifest_version": 3` (MV2 rejected by the Chrome Web Store).'
16
+ notMV3: 'manifest.json must declare `"manifest_version": 3`.'
17
17
  }
18
18
  },
19
19
 
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { findExtensionRoot, getFilename } from '../utils.js'
5
+
6
+ const re = { manifestFilename: /manifest\.json$/, svg: /\.svg$/i },
7
+ sizes = { recommended: ['16', '32', '48', '128'], required: '128' }
8
+
9
+ export default {
10
+ meta: {
11
+ type: 'problem',
12
+ docs: {
13
+ description: 'Validate manifest icons: sizes, existing files, no SVG',
14
+ category: 'Possible Errors',
15
+ recommended: false,
16
+ manifest: true
17
+ },
18
+ defaultOptions: [{
19
+ requireRecommendedSizes: true,
20
+ checkIconExists: true,
21
+ noSVG: true
22
+ }],
23
+ schema: [{
24
+ type: 'object',
25
+ properties: {
26
+ requireRecommendedSizes: {
27
+ type: 'boolean',
28
+ description: 'Require all recommended sizes (16, 32, 48, 128)'
29
+ },
30
+ checkIconExists: {
31
+ type: 'boolean',
32
+ description: 'Flag icon paths that do not resolve to a file'
33
+ },
34
+ noSVG: {
35
+ type: 'boolean',
36
+ description: 'Flag SVG icon paths (unsupported by Chrome)'
37
+ }
38
+ },
39
+ additionalProperties: false
40
+ }],
41
+ messages: {
42
+ missingSize: 'Recommended icon size "{{size}}" is missing (16, 32, 48, 128).',
43
+ missingRequiredSize: 'Icons must include a 128x128 icon.',
44
+ missingFile: 'Icon file "{{file}}" does not exist in the extension root.',
45
+ svgNotSupported: 'SVG icons are not supported by Chrome. Use PNG instead.'
46
+ }
47
+ },
48
+
49
+ create(context) {
50
+ if (!re.manifestFilename.test(getFilename(context))) return {}
51
+ const opts = context.options[0] || {},
52
+ requireRecommended = opts.requireRecommendedSizes !== false,
53
+ checkFiles = opts.checkIconExists !== false,
54
+ checkSVG = opts.noSVG !== false
55
+ const root = checkFiles ? findExtensionRoot(context) : null
56
+ return {
57
+ '*'(node) {
58
+ const keyNode = node.name || node.key ; if (!keyNode) return
59
+ const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'icons') return
60
+ const iconsObj = node.value ; if (iconsObj?.type != 'Object') return
61
+ const members = iconsObj.members || iconsObj.properties || []
62
+ const declaredSizes = new Set()
63
+ for (const member of members) {
64
+ const sizeNode = member.name || member.key,
65
+ size = sizeNode?.value ?? sizeNode?.name,
66
+ fileNode = member.value,
67
+ file = fileNode?.value
68
+ if (typeof size == 'string') declaredSizes.add(size)
69
+ if (typeof file != 'string') continue
70
+ if (checkSVG && re.svg.test(file))
71
+ context.report({ node: fileNode, messageId: 'svgNotSupported' })
72
+ else if (checkFiles && root // eslint-disable-next-line security/detect-non-literal-fs-filename
73
+ && !fs.existsSync(path.resolve(root, file))
74
+ ) context.report({ node: fileNode, messageId: 'missingFile', data: { file }})
75
+ }
76
+ if (requireRecommended) {
77
+ for (const size of sizes.recommended)
78
+ if (!declaredSizes.has(size))
79
+ context.report({ node: iconsObj, messageId: 'missingSize', data: { size }})
80
+ } else if (!declaredSizes.has(sizes.required))
81
+ context.report({ node: iconsObj, messageId: 'missingRequiredSize' })
82
+ }
83
+ }
84
+ }
85
+ }
@@ -1,5 +1,6 @@
1
- import { mv2OnlyKeys, forbiddenCSPdirectives } from '../data/manifest.js'
2
1
  import { getFilename, readManifest } from '../utils.js'
2
+ import { mv2OnlyKeys, forbiddenCSPdirectives, ffMV3bgKeys, ffDataCollectionRequired, ffDataCollectionOptional }
3
+ from '../data/manifest.js'
3
4
 
4
5
  const re = { manifestFilename: /manifest\.json$/ }
5
6
 
@@ -9,12 +10,13 @@ export default {
9
10
  docs: {
10
11
  description: 'Validate manifest against the MV3 schema',
11
12
  category: 'Possible Errors',
12
- recommended: true,
13
+ recommended: false,
13
14
  manifest: true
14
15
  },
15
16
  schema: [],
16
17
  messages: {
17
- mv2Key: '`{{key}}` is not allowed in MV3 - {{reason}}.',
18
+ invalidDataCollectionValue: '`{{value}}` is not a valid data collection permission value for {{kind}}.',
19
+ mv2Key: '`{{key}}` is not allowed in MV3. {{reason}}.',
18
20
  unsafeCSP: 'MV3 CSP must not include `{{directive}}`.',
19
21
  wrongCSPType: '`content_security_policy` must be an object in MV3, not a string.'
20
22
  }
@@ -22,11 +24,11 @@ export default {
22
24
 
23
25
  create(context) {
24
26
  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
27
+ const ancestors = [], pendingReports = [], deferredMV2 = []
28
+ let isMV3 = false, isFF = false
29
+ function keyOf(node) {
30
+ const keyNode = node?.name || node?.key
31
+ return keyNode?.value ?? keyNode?.name
30
32
  }
31
33
  return {
32
34
  '*'(node) {
@@ -35,18 +37,23 @@ export default {
35
37
  const isTopLevel = ancestors.length == 2
36
38
  && ancestors[0].type == 'Document'
37
39
  && 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
+ const inBackground = ancestors.some(ancestor => keyOf(ancestor) == 'background')
41
+ const inCSP = ancestors.some(ancestor => keyOf(ancestor) == 'content_security_policy')
42
+ const inDataCollection = ancestors.some(ancestor =>
43
+ keyOf(ancestor) == 'data_collection_permissions')
40
44
  if (keyName == 'manifest_version' && node.value?.value == 3) isMV3 = true
45
+ if (isTopLevel && keyName == 'browser_specific_settings') isFF = true
41
46
  if (isTopLevel && mv2OnlyKeys.has(keyName))
42
47
  pendingReports.push({ node, messageId: 'mv2Key',
43
48
  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' }})
49
+ if (inBackground && ffMV3bgKeys.has(keyName))
50
+ deferredMV2.push({ node, messageId: 'mv2Key',
51
+ data: { key: `background.${keyName}`,
52
+ reason: 'Use background.service_worker instead' }})
47
53
  if (inBackground && keyName == 'persistent')
48
54
  pendingReports.push({ node, messageId: 'mv2Key',
49
- data: { key: 'background.persistent', reason: 'MV3 service workers are never persistent' }})
55
+ data: { key: 'background.persistent',
56
+ reason: 'MV3 service workers are never persistent' }})
50
57
  if (isTopLevel && keyName == 'content_security_policy' && node.value?.type == 'String')
51
58
  pendingReports.push({ node: node.value, messageId: 'wrongCSPType' })
52
59
  if (inCSP && keyName == 'extension_pages' && typeof node.value?.value == 'string')
@@ -54,6 +61,17 @@ export default {
54
61
  if (node.value.value.includes(directive))
55
62
  pendingReports.push({ node: node.value, messageId: 'unsafeCSP',
56
63
  data: { directive }})
64
+ if (inDataCollection && (keyName == 'required' || keyName == 'optional')
65
+ && Array.isArray(node.value?.elements)
66
+ ) for (const el of node.value.elements) {
67
+ const value = typeof el.value == 'string' ? el.value : el.value?.value
68
+ if (typeof value == 'string') {
69
+ const set = keyName == 'required' ? ffDataCollectionRequired : ffDataCollectionOptional
70
+ if (!set.has(value))
71
+ pendingReports.push({ node: el, messageId: 'invalidDataCollectionValue',
72
+ data: { value, kind: keyName }})
73
+ }
74
+ }
57
75
  }
58
76
  ancestors.push(node)
59
77
  },
@@ -61,20 +79,23 @@ export default {
61
79
  ancestors.pop()
62
80
  if (node.type == 'Document') {
63
81
  if (!isMV3) return
64
- for (const r of pendingReports) context.report(r)
82
+ if (!isFF) pendingReports.push(...deferredMV2)
83
+ for (const report of pendingReports) context.report(report)
65
84
  }
66
85
  }
67
86
  }
68
87
  }
69
88
  const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version != 3) return {}
89
+ const isFF = manifest.browser_specific_settings != null
70
90
  return {
71
- Program(node) {
91
+ Program(node) { /* eslint-disable security/detect-object-injection */
72
92
  for (const key of Object.keys(manifest))
73
93
  if (mv2OnlyKeys.has(key))
74
94
  context.report({ node, messageId: 'mv2Key', data: { key, reason: 'MV2-only key' }})
75
- if (manifest.background?.scripts)
76
- context.report({ node, messageId: 'mv2Key',
77
- data: { key: 'background.scripts', reason: 'use background.service_worker' }})
95
+ for (const key of ffMV3bgKeys)
96
+ if (manifest.background?.[key] && !isFF)
97
+ context.report({ node, messageId: 'mv2Key',
98
+ data: { key: `background.${key}`, reason: 'Use background.service_worker instead' }})
78
99
  if (manifest.background?.persistent != null)
79
100
  context.report({ node, messageId: 'mv2Key',
80
101
  data: { key: 'background.persistent', reason: 'MV3 service workers are never persistent' }})
@@ -86,7 +107,16 @@ export default {
86
107
  for (const directive of forbiddenCSPdirectives)
87
108
  if (cspPages.includes(directive))
88
109
  context.report({ node, messageId: 'unsafeCSP', data: { directive }})
89
- }
110
+ if (isFF) { // validate data collection permissions
111
+ const dcp = manifest.browser_specific_settings?.gecko?.data_collection_permissions
112
+ for (const [kind, set] of [['required', ffDataCollectionRequired], ['optional', ffDataCollectionOptional]])
113
+ if (Array.isArray(dcp?.[kind]))
114
+ for (const value of dcp[kind])
115
+ if (!set.has(value))
116
+ context.report({ node, messageId: 'invalidDataCollectionValue',
117
+ data: { value, kind }})
118
+ }
119
+ } /* eslint-enable security/detect-object-injection */
90
120
  }
91
121
  }
92
122
  }
@@ -12,7 +12,7 @@ export default {
12
12
  schema: [],
13
13
  messages: {
14
14
  listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level so it attaches on service worker wake-up.',
15
- keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive - use `chrome.alarms` instead.',
15
+ keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive. Use `chrome.alarms` instead.',
16
16
  domInSW: '`{{global}}` is not available in a service worker.'
17
17
  }
18
18
  },
@@ -30,13 +30,18 @@ export default {
30
30
  && node.arguments[1]?.type == 'Literal'
31
31
  && typeof node.arguments[1].value == 'number'
32
32
  && node.arguments[1].value < serviceWorkerKeepaliveMinMS
33
+ && !isInsideNestedFunction(node)
33
34
  ) context.report({ node, messageId: 'keepaliveTimer', data: { fn: node.callee.name }})
34
35
  },
35
36
  Identifier(node) {
36
37
  if (node.parent?.type == 'MemberExpression' && node.parent.property === node) return
37
38
  if (node.parent?.type == 'Property' && node.parent.key === node && !node.parent.computed) return
38
- if (domGlobals.has(node.name))
39
- context.report({ node, messageId: 'domInSW', data: { global: node.name }})
39
+ if (!domGlobals.has(node.name)) return
40
+ for (let scope = context.sourceCode.getScope(node) ;
41
+ scope && scope.type != 'global' ;
42
+ scope = scope.upper
43
+ ) if (scope.set.has(node.name)) return // shadowed by a local binding
44
+ context.report({ node, messageId: 'domInSW', data: { global: node.name }})
40
45
  }
41
46
  }
42
47
  }
@@ -13,7 +13,7 @@ export default {
13
13
  docs: {
14
14
  description: 'Validate the manifest version field format',
15
15
  category: 'Possible Errors',
16
- recommended: true,
16
+ recommended: false,
17
17
  manifest: true
18
18
  },
19
19
  schema: [{
@@ -37,8 +37,8 @@ export default {
37
37
  }],
38
38
  defaultOptions: [{}],
39
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'
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
42
  }
43
43
  },
44
44
 
package/src/utils.js CHANGED
@@ -10,6 +10,17 @@ export function bundleRules(rules, predicate, pluginName) {
10
10
  .map(([ruleId]) => [`${pluginName}/${ruleId}`, 'error']))
11
11
  }
12
12
 
13
+ export function initBundleDeprecation(plugin, [ruleId, bundleNames], pluginName) {
14
+ for (const bundleName of bundleNames) {
15
+ /* eslint-disable security/detect-object-injection */
16
+ if (plugin.configs[bundleName])
17
+ plugin.configs[bundleName].rules[`${pluginName}/${ruleId}`] = 'error'
18
+ /* eslint-enable security/detect-object-injection */
19
+ if (process.env.DEBUG_MODE)
20
+ console.warn(`[${pluginName}] '${ruleId}' will be removed from '${bundleName}' in a future major release`)
21
+ }
22
+ }
23
+
13
24
  export function findExtensionRoot(context) {
14
25
  let dir = path.dirname(getFilename(context))
15
26
  while (dir) {