eslint-plugin-chrome-extensions 2.12.0 → 2.12.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "2.12.0",
3
+ "version": "2.12.2",
4
4
  "description": "ESLint rules for Chrome extensions",
5
5
  "keywords": [
6
6
  "eslint",
@@ -61,10 +61,13 @@
61
61
  "test": "node tests/run"
62
62
  },
63
63
  "overrides": {
64
- "@eslint/config-array": {
64
+ "@eslint/markdown": {
65
+ "katex": "^0.19.1"
66
+ },
67
+ "eslint": {
65
68
  "minimatch": "^11.0.5"
66
69
  },
67
- "@typescript-eslint/typescript-estree": {
70
+ "eslint-plugin-import-x": {
68
71
  "minimatch": "^11.0.5"
69
72
  },
70
73
  "eslint-plugin-sonarjs": {
@@ -80,15 +83,15 @@
80
83
  "@e18e/eslint-plugin": "^0.8.1",
81
84
  "@eslint/css": "^2.0.1",
82
85
  "@eslint/json": "^2.1.1",
83
- "@eslint/markdown": "^7.5.1",
84
- "@html-eslint/eslint-plugin": "^0.67.0",
86
+ "@eslint/markdown": "^8.0.3",
87
+ "@html-eslint/eslint-plugin": "^0.68.0",
85
88
  "@stylistic/eslint-plugin": "^5.10.0",
86
- "@typescript-eslint/parser": "^8.72.1",
87
- "@typescript-eslint/utils": "^8.72.1",
89
+ "@typescript-eslint/parser": "^8.72.2",
90
+ "@typescript-eslint/utils": "^8.72.2",
88
91
  "eslint": "^10.14.0",
89
- "eslint-plugin-constructors": "^1.7.1",
92
+ "eslint-plugin-constructors": "^1.7.2",
90
93
  "eslint-plugin-eslint-plugin": "^7.6.2",
91
- "eslint-plugin-import-x": "^4.18.0",
94
+ "eslint-plugin-import-x": "^4.18.1",
92
95
  "eslint-plugin-n": "^18.5.0",
93
96
  "eslint-plugin-no-secrets": "^2.3.3",
94
97
  "eslint-plugin-no-unsanitized": "^4.1.5",
@@ -97,8 +100,8 @@
97
100
  "eslint-plugin-regexp": "^3.3.3",
98
101
  "eslint-plugin-security": "^4.2.1",
99
102
  "eslint-plugin-sonarjs": "^4.2.2",
100
- "eslint-plugin-woodpecker": "^1.1.2",
101
- "oxlint": "^1.91.0",
103
+ "eslint-plugin-woodpecker": "^1.1.4",
104
+ "oxlint": "^1.93.0",
102
105
  "typescript": "npm:@typescript/typescript6@^6.0.2",
103
106
  "typescript-latest": "npm:typescript@^7.0.2"
104
107
  },
@@ -5,11 +5,6 @@ import regex from '../data/regex.js'
5
5
 
6
6
  const defaultOptions = { order: [] }
7
7
 
8
- function keyOf(member) {
9
- const keyNode = member.name || member.key
10
- return keyNode?.value ?? keyNode?.name
11
- }
12
-
13
8
  function findViolation(members, keys) {
14
9
  const ranks = new Map(keys.map((key, index) => [key, index])),
15
10
  known = members.map(member => {
@@ -17,7 +12,26 @@ function findViolation(members, keys) {
17
12
  return { member, key, rank: ranks.get(key) }
18
13
  }).filter(item => item.rank !== undefined)
19
14
  for (let i = 1 ; i < known.length ; i++) // oxlint-disable-next-line security/detect-object-injection
20
- if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
15
+ if (known[i - 1].rank > known[i].rank) return [known[i - 1], known[i]]
16
+ }
17
+ function isSubsequence(sub, arr) {
18
+ let i = 0
19
+ for (const item of arr) if (item === sub[i]) i++ // oxlint-disable-line security/detect-object-injection
20
+ return i === sub.length
21
+ }
22
+ function keyOf(member) {
23
+ const keyNode = member.name || member.key
24
+ return keyNode?.value ?? keyNode?.name
25
+ }
26
+ function mergeOrder(master, custom) {
27
+ if (!custom.length || isSubsequence(custom, master)) return master
28
+ const customSet = new Set(custom),
29
+ insertAt = Math.min(...custom.map(key => master.indexOf(key)).filter(idx => idx >= 0))
30
+ return [
31
+ ...master.slice(0, insertAt),
32
+ ...custom,
33
+ ...master.slice(insertAt).filter(key => !customSet.has(key))
34
+ ]
21
35
  }
22
36
 
23
37
  export default {
@@ -46,14 +60,12 @@ export default {
46
60
  create(context) {
47
61
  if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
48
62
  const { order } = { ...defaultOptions, ...context.options[0] },
49
- master = order.length ? manifest.keys.order.filter(key => !order.includes(key)) : manifest.keys.order
63
+ keys = mergeOrder(manifest.keys.order, order)
50
64
  return {
51
65
  'Document:exit'(document) {
52
66
  const root = document.body ; if (root?.type != 'Object') return
53
67
  const members = root.members || root.properties || []
54
- let keys = master, violation = findViolation(members, master)
55
- if (!violation && order.length) { keys = order ; violation = findViolation(members, order) }
56
- if (!violation) return
68
+ const violation = findViolation(members, keys) ; if (!violation) return
57
69
  const [first, second] = violation
58
70
  context.report({
59
71
  node: second.member, messageId: 'wrongOrder',
@@ -1,18 +1,30 @@
1
1
  import extension from '../utils/extension.js'
2
2
  import nodeUtils from '../utils/node.js'
3
3
 
4
- const re = { appendMethod: /^append(?:Child)?$/ }
4
+ const re = {
5
+ appendMethod: /^(?:append|appendChild|prepend|replaceChildren|insertBefore)$/,
6
+ invokeMethod: /^(?:apply|call)$/,
7
+ styleName: /style/i
8
+ }
5
9
 
10
+ function flattenArgs(args) {
11
+ return args.filter(Boolean).flatMap(arg =>
12
+ arg.type != 'SpreadElement' ? [arg]
13
+ : arg.argument?.type == 'ArrayExpression' ? arg.argument.elements.filter(Boolean)
14
+ : [arg.argument]
15
+ )
16
+ }
17
+ function getPropName(node) { return node?.name ?? node?.value }
6
18
  function isCreateStyle(node) {
7
19
  return node?.type == 'CallExpression'
8
- && node.callee?.property?.name == 'createElement'
20
+ && getPropName(node.callee?.property) == 'createElement'
9
21
  && nodeUtils.getStringLiteral(node.arguments[0]) == 'style'
10
22
  }
11
23
  function isObjectAssign(node) {
12
24
  return node?.type == 'CallExpression'
13
25
  && node.callee?.type == 'MemberExpression'
14
26
  && node.callee.object?.name == 'Object'
15
- && node.callee.property?.name == 'assign'
27
+ && getPropName(node.callee.property) == 'assign'
16
28
  }
17
29
 
18
30
  export default {
@@ -37,33 +49,60 @@ export default {
37
49
  return node?.type == 'Identifier'
38
50
  && (node.name == 'document' || docAliases.has(node.name))
39
51
  }
40
- function isDocumentMember(node) {
41
- return node?.type == 'MemberExpression' && isDocumentRef(node.object)
52
+ function isDocRooted(node) {
53
+ if (!node) return false
54
+ if (node.type == 'Identifier') return isDocumentRef(node)
55
+ if (node.type == 'MemberExpression')
56
+ return node.object?.type == 'Identifier'
57
+ && (node.object.name == 'window' || node.object.name == 'self')
58
+ && getPropName(node.property) == 'document'
59
+ || isDocRooted(node.object)
60
+ if (node.type == 'CallExpression' && getPropName(node.callee?.property) != 'createElement')
61
+ return isDocRooted(node.callee)
62
+ return false
42
63
  }
43
64
  function isStyleTarget(node) {
44
- if (isCreateStyle(node)) return true
45
- if (node?.type == 'Identifier' && styleElems.has(node.name)) return true
46
- if (isObjectAssign(node) && isCreateStyle(node.arguments[0])) return true
47
- if (isObjectAssign(node)
48
- && node.arguments[0]?.type == 'Identifier'
49
- && styleElems.has(node.arguments[0].name)
65
+ if (!node) return false
66
+ if (isCreateStyle(node)
67
+ || node.type == 'Identifier' && (styleElems.has(node.name) || re.styleName.test(node.name))
50
68
  ) return true
69
+ if (isObjectAssign(node)) {
70
+ const target = node.arguments[0]
71
+ if (isCreateStyle(target)
72
+ || target?.type == 'Identifier' && styleElems.has(target.name)
73
+ ) return true
74
+ }
51
75
  return false
52
76
  }
77
+ function addAliases(pattern, source) {
78
+ if (!isDocRooted(source)) return
79
+ if (pattern.type == 'Identifier') docAliases.add(pattern.name)
80
+ else if (pattern.type == 'ObjectPattern')
81
+ for (const prop of pattern.properties) {
82
+ if (prop.type != 'Property') continue
83
+ if (prop.value?.type == 'Identifier') docAliases.add(prop.value.name)
84
+ }
85
+ }
53
86
  return {
54
87
  VariableDeclarator(node) {
55
- if (node.id.type != 'Identifier') return
56
- if (isCreateStyle(nodeUtils.unwrapTS(node.init))) styleElems.add(node.id.name)
57
- if (nodeUtils.unwrapTS(node.init)?.type == 'Identifier' && isDocumentRef(node.init))
58
- docAliases.add(node.id.name)
88
+ const init = nodeUtils.unwrapTS(node.init)
89
+ if (node.id.type == 'Identifier' && isCreateStyle(init)) styleElems.add(node.id.name)
90
+ addAliases(node.id, init)
59
91
  },
60
92
  CallExpression(node) {
61
93
  if (node.callee.type != 'MemberExpression') return
62
- const method = node.callee.property.name
63
- if (!re.appendMethod.test(method)
64
- || !isDocumentMember(node.callee.object)
94
+ const outerMethod = getPropName(node.callee.property),
95
+ isInvoke = re.invokeMethod.test(outerMethod),
96
+ callee = isInvoke ? node.callee.object : node.callee
97
+ if (callee?.type != 'MemberExpression'
98
+ || !re.appendMethod.test(getPropName(callee.property))
99
+ || !isDocRooted(callee.object)
65
100
  ) return
66
- for (const arg of node.arguments)
101
+ const args = !isInvoke ? node.arguments
102
+ : outerMethod == 'apply'
103
+ ? node.arguments[1]?.type == 'ArrayExpression' ? node.arguments[1].elements : []
104
+ : node.arguments.slice(1)
105
+ for (const arg of flattenArgs(args))
67
106
  if (isStyleTarget(arg))
68
107
  context.report({ node: arg, messageId: 'styleLeak' })
69
108
  }
@@ -6,27 +6,57 @@ import path from 'node:path'
6
6
  import apis from '../data/apis.js'
7
7
  import regex from '../data/regex.js'
8
8
 
9
- function collectUsedAPIs(manifest, root) {
10
- const files = new Set()
11
- if (manifest.background?.service_worker) files.add(manifest.background.service_worker)
12
- for (const script of manifest.background?.scripts || []) files.add(script)
9
+ const re = {
10
+ chromeAPI: /\bchrome\.\w+(?:\.\w+)*/g, // oxlint-disable-line security/detect-unsafe-regex
11
+ htmlExt: /\.html?$/i,
12
+ htmlScriptSrc: /<script\b[^>]*?\ssrc\s*=\s*(?:"([^"]+)"|'([^']+)')/gi,
13
+ urlLike: /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
14
+ }
15
+
16
+ function collectScannableFiles(manifest) {
17
+ const files = new Set(),
18
+ add = file => { if (typeof file == 'string' && file) files.add(file) }
19
+ add(manifest.background?.service_worker)
20
+ add(manifest.background?.page)
21
+ for (const script of manifest.background?.scripts || []) add(script)
13
22
  for (const contentScript of manifest.content_scripts || [])
14
- for (const file of contentScript.js || []) files.add(file)
15
- if (manifest.action?.default_popup) files.add(manifest.action.default_popup)
16
- if (manifest.options_ui?.page) files.add(manifest.options_ui.page)
17
- if (manifest.devtools_page) files.add(manifest.devtools_page)
18
- const re = { /* oxlint-disable-next-line security/detect-unsafe-regex */
19
- chromeAPI: /\bchrome\.\w+(?:\.\w+)*/g }
20
- const used = new Set()
21
- for (const file of files) {
22
- try {
23
- const src = // oxlint-disable-next-line security/detect-non-literal-fs-filename
24
- fs.readFileSync(path.resolve(root, file), 'utf8')
25
- for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
26
- } catch {}
23
+ for (const file of contentScript.js || []) add(file)
24
+ add(manifest.action?.default_popup)
25
+ add(manifest.browser_action?.default_popup)
26
+ add(manifest.page_action?.default_popup)
27
+ add(manifest.options_ui?.page)
28
+ add(manifest.options_page)
29
+ add(manifest.devtools_page)
30
+ add(manifest.side_panel?.default_path)
31
+ for (const page of Object.values(manifest.chrome_url_overrides || {})) add(page)
32
+ return files
33
+ }
34
+ function collectUsedAPIs(manifest, root) {
35
+ const queue = [...collectScannableFiles(manifest)],
36
+ visited = new Set(),
37
+ used = new Set()
38
+ while (queue.length) {
39
+ const relPath = queue.shift() ; if (!relPath) continue
40
+ const absPath = path.resolve(root, relPath) ; if (visited.has(absPath)) continue
41
+ visited.add(absPath)
42
+ let src
43
+ try { src = fs.readFileSync(absPath, 'utf8') } // oxlint-disable-line security/detect-non-literal-fs-filename
44
+ catch { continue }
45
+ for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
46
+ if (re.htmlExt.test(relPath))
47
+ for (const nested of extractScriptSrcs(src, relPath)) queue.push(nested)
27
48
  }
28
49
  return used
29
50
  }
51
+ function extractScriptSrcs(html, htmlRelPath) {
52
+ const srcs = []
53
+ for (const match of html.matchAll(re.htmlScriptSrc)) {
54
+ const src = (match[1] ?? match[2])?.trim()
55
+ if (!src || re.urlLike.test(src)) continue
56
+ srcs.push(src.startsWith('/') ? src.slice(1) : path.join(path.dirname(htmlRelPath), src))
57
+ }
58
+ return srcs
59
+ }
30
60
 
31
61
  export default {
32
62
  meta: {
@@ -50,10 +80,10 @@ export default {
50
80
  const root = extension.findRoot(context) ; if (!root) return {}
51
81
  const usedAPIs = collectUsedAPIs(manifest, root)
52
82
  function isUsed(permission) {
53
- let knownPerm = false
54
- for (const [, perm] of apis.permissionMap)
55
- if (perm == permission) { knownPerm = true ; break }
56
- if (!knownPerm) return true
83
+ let knownPermission = false
84
+ for (const [, mappedPermission] of apis.permissionMap)
85
+ if (mappedPermission == permission) { knownPermission = true ; break }
86
+ if (!knownPermission) return true
57
87
  for (const api of usedAPIs)
58
88
  if (apiUtils.findPermission(api) == permission) return true
59
89
  return false
@@ -72,9 +102,9 @@ export default {
72
102
  const keyNode = node.name || node.key ; if (!keyNode) return
73
103
  const keyName = keyNode.value ?? keyNode.name
74
104
  if (keyName != 'permissions' && keyName != 'optional_permissions') return
75
- const arrNode = node.value ; if (arrNode?.type != 'Array') return
76
- const nodeElems = arrNode.elements || arrNode.items || []
77
- for (const elem of nodeElems) {
105
+ const arrayNode = node.value ; if (arrayNode?.type != 'Array') return
106
+ const elems = arrayNode.elements || arrayNode.items || []
107
+ for (const elem of elems) {
78
108
  const valueNode = elem.value || elem,
79
109
  permission = valueNode?.value
80
110
  if (typeof permission != 'string') continue
@@ -58,8 +58,9 @@ export default {
58
58
  allowedLicenses = array.to(rawAllowedLicenses),
59
59
  { filename } = context,
60
60
  basename = path.basename(filename)
61
- if (!regex.filename.license.test(basename) || regex.filename.script.test(basename))
62
- return {}
63
- return { Program: node => validateContent(context, node, { requireSPDX, allowedLicenses })}
61
+ return !regex.filename.license.test(basename) || regex.filename.script.test(basename) ? {} : {
62
+ Program: node => validateContent(context, node, { requireSPDX, allowedLicenses }),
63
+ Document: node => validateContent(context, node, { requireSPDX, allowedLicenses })
64
+ }
64
65
  }
65
66
  }