eslint-plugin-greasemonkey 1.8.4 → 1.10.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 (45) hide show
  1. package/README.md +776 -68
  2. package/docs/LICENSE.md +1 -1
  3. package/package.json +53 -43
  4. package/src/data/apis.js +56 -53
  5. package/src/data/headers.js +65 -31
  6. package/src/data/regex.js +14 -0
  7. package/src/index.d.ts +34 -3
  8. package/src/index.js +5 -80
  9. package/src/rules/meta-order.js +5 -6
  10. package/src/rules/meta-spacing.js +11 -10
  11. package/src/rules/no-duplicate-keys.js +2 -2
  12. package/src/rules/no-insecure-fetch.js +108 -0
  13. package/src/rules/no-insecure-messaging.js +67 -0
  14. package/src/rules/no-invalid-grants.js +12 -10
  15. package/src/rules/no-invalid-headers.js +11 -11
  16. package/src/rules/no-missing-grants.js +6 -8
  17. package/src/rules/no-missing-resources.js +10 -8
  18. package/src/rules/no-sensitive-grants.js +81 -0
  19. package/src/rules/no-unused-grants.js +2 -2
  20. package/src/rules/no-unused-resources.js +10 -8
  21. package/src/rules/prefer-gm-add-style.js +67 -0
  22. package/src/rules/prefer-match.js +4 -3
  23. package/src/rules/require-homepage-pair.js +3 -4
  24. package/src/rules/require-license-header.js +27 -0
  25. package/src/rules/require-matching-localized-fields.js +2 -2
  26. package/src/rules/require-required-fields.js +2 -2
  27. package/src/rules/require-space-before-meta-key.js +4 -5
  28. package/src/rules/require-sri-hashing.js +3 -3
  29. package/src/rules/require-update-download-pair.js +3 -4
  30. package/src/rules/require-user-js-filename.js +2 -2
  31. package/src/rules/valid-field-values.js +7 -7
  32. package/src/rules/valid-icon-headers.js +74 -0
  33. package/src/rules/valid-license-header.js +73 -0
  34. package/src/rules/valid-url-headers.js +114 -0
  35. package/src/rules/valid-version-header.js +83 -0
  36. package/src/utils/api.js +11 -0
  37. package/src/utils/array.js +1 -0
  38. package/src/utils/deprecations.js +76 -0
  39. package/src/utils/init.js +106 -0
  40. package/src/utils/license.js +56 -0
  41. package/src/utils/metadata.js +31 -0
  42. package/src/utils/pkg.js +21 -0
  43. package/src/utils/rules.js +7 -0
  44. package/src/utils/template.js +21 -0
  45. package/src/utils.js +0 -119
@@ -0,0 +1,108 @@
1
+ import headers from '../data/headers.js'
2
+ import regex from '../data/regex.js'
3
+
4
+ const re = {
5
+ insecureURL: /^http:\/\/(?!localhost\b|127\.0\.0\.1\b|\[::1\])/i,
6
+ meta: {
7
+ end: /^\/\/\s*==\/UserScript==\s*$/,
8
+ line: /^\/\/\s*@([\w-]+)(?::[\w-]+)?\s+(\S.*)$/, // oxlint-disable-line security/detect-unsafe-regex
9
+ start: /^\/\/\s*==UserScript==\s*$/
10
+ }
11
+ }
12
+
13
+ function isInsecure(url) { return typeof url == 'string' && re.insecureURL.test(url) }
14
+ function urlFromLiteral(node) {
15
+ return (node?.type == 'Literal'
16
+ && typeof node.value == 'string'
17
+ && isInsecure(node.value)
18
+ ) ? node.value : null
19
+ }
20
+ function urlFromOptions(node) {
21
+ if (node?.type != 'ObjectExpression') return null
22
+ for (const prop of node.properties)
23
+ if (prop.type == 'Property' && (prop.key?.name == 'url' || prop.key?.value == 'url')) {
24
+ const val = prop.value
25
+ if (val?.type == 'Literal' && isInsecure(val.value)) return val.value
26
+ }
27
+ return null
28
+ }
29
+
30
+ export default {
31
+ meta: {
32
+ type: 'problem',
33
+ docs: {
34
+ description: 'Flags `fetch()`, `XMLHttpRequest.open()`, `GM_xmlhttpRequest()`, `GM.xmlHttpRequest()` calls and URL fields that use `http://`',
35
+ category: 'Possible Errors',
36
+ recommended: true,
37
+ security: true
38
+ },
39
+ schema: [],
40
+ messages: {
41
+ insecureFetch: '`fetch()` to "{{url}}" insecure. Use HTTPS or a relative path instead.',
42
+ insecureXHR: '`XMLHttpRequest.open()` to "{{url}}" insecure. Use HTTPS or a relative path instead.',
43
+ insecureGMXHR: '`{{callee}}()` to "{{url}}" insecure. Use HTTPS or a relative path instead.',
44
+ insecureMetadata: '`@{{key}}` URL "{{url}}" insecure. Use HTTPS instead.'
45
+ }
46
+ },
47
+
48
+ create(context) {
49
+ const xhrVars = new Set(),
50
+ sourceCode = context.sourceCode ?? context.getSourceCode()
51
+ function reportMetadata() {
52
+ const lines = sourceCode.text.split(regex.newline)
53
+ let inBlock = false
54
+ for (const [i, rawLine] of lines.entries()) {
55
+ const line = rawLine.trimEnd()
56
+ if (re.meta.start.test(line.trim())) { inBlock = true ; continue }
57
+ if (inBlock && re.meta.end.test(line.trim())) break
58
+ if (!inBlock) continue
59
+ const match = line.match(re.meta.line) ; if (!match) continue
60
+ const key = match[1]
61
+ const type = headers.url.get(key) ; if (!type) continue
62
+ const tokens = match[2].split(regex.whitespace.all),
63
+ url = type == 'named-url' ? tokens[1] : tokens[0]
64
+ if (!url || !isInsecure(url)) continue
65
+ const col = line.lastIndexOf(url)
66
+ context.report({
67
+ loc: { start: { line: i +1, column: col },
68
+ end: { line: i +1, column: col + url.length }},
69
+ messageId: 'insecureMetadata', data: { key, url }
70
+ })
71
+ }
72
+ }
73
+ function reportGMXHR(node) {
74
+ const url = urlFromLiteral(node.arguments[0]) ?? urlFromOptions(node.arguments[0]) ; if (!url) return
75
+ const callee = node.callee.type == 'MemberExpression'
76
+ ? `${node.callee.object.name}.${node.callee.property.name}`
77
+ : node.callee.name
78
+ context.report({ node: node.arguments[0], messageId: 'insecureGMXHR', data: { callee, url }})
79
+ }
80
+ return {
81
+ Program() { reportMetadata() },
82
+ VariableDeclarator(node) {
83
+ if (node.init?.type == 'NewExpression'
84
+ && node.init.callee?.name == 'XMLHttpRequest'
85
+ && node.id.type == 'Identifier'
86
+ ) xhrVars.add(node.id.name)
87
+ },
88
+ CallExpression(node) {
89
+ if (node.callee.type == 'Identifier' && node.callee.name == 'fetch') {
90
+ const url = urlFromLiteral(node.arguments[0])
91
+ if (url) context.report({ node: node.arguments[0], messageId: 'insecureFetch', data: { url }})
92
+ } else if (node.callee.type == 'MemberExpression'
93
+ && node.callee.property?.name == 'open'
94
+ && node.callee.object?.type == 'Identifier'
95
+ && xhrVars.has(node.callee.object.name)
96
+ ) { // xhr.open('GET', 'http://…')
97
+ const url = urlFromLiteral(node.arguments[1])
98
+ if (url) context.report({ node: node.arguments[1], messageId: 'insecureXHR', data: { url }})
99
+ } else if (node.callee.type == 'Identifier'
100
+ && node.callee.name == 'GM_xmlhttpRequest'
101
+ || node.callee.type == 'MemberExpression'
102
+ && node.callee.object?.name == 'GM'
103
+ && node.callee.property?.name == 'xmlHttpRequest'
104
+ ) reportGMXHR(node)
105
+ }
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,67 @@
1
+ const re = {
2
+ messageTargets: new Set(['window', 'self', 'unsafeWindow']),
3
+ postmsgValidation: /\b\w+\.(?:origin|source)\b/
4
+ }
5
+
6
+ function isMessageTarget(obj) { return obj?.type == 'Identifier' && re.messageTargets.has(obj.name) }
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'problem',
11
+ docs: {
12
+ description: 'Requires origin/source validation + explicit `postMessage()` target',
13
+ category: 'Possible Errors',
14
+ recommended: true,
15
+ security: true
16
+ },
17
+ defaultOptions: [{ disallowWildcardTarget: true }],
18
+ schema: [{
19
+ type: 'object',
20
+ properties: {
21
+ disallowWildcardTarget: {
22
+ type: 'boolean',
23
+ description: 'Flag postMessage calls that use "*" as targetOrigin'
24
+ }
25
+ },
26
+ additionalProperties: false
27
+ }],
28
+ messages: {
29
+ unvalidatedPostMessage: '`{{target}}` message handler must validate `event.origin`/`event.source` '
30
+ + 'before processing messages.',
31
+ wildcardTarget: '`postMessage` to `"*"` leaks data to any origin. '
32
+ + 'Explicitly specify `targetOrigin` instead.'
33
+ }
34
+ },
35
+
36
+ create(context) {
37
+ const opts = context.options[0] || {},
38
+ disallowWildcard = opts.disallowWildcardTarget !== false,
39
+ sourceCode = context.sourceCode ?? context.getSourceCode()
40
+ function checkPostMessageHandler(node, handlerNode, target) {
41
+ if (!handlerNode) return
42
+ const body = sourceCode.getText(handlerNode)
43
+ if (re.postmsgValidation.test(body)) return
44
+ context.report({ node, messageId: 'unvalidatedPostMessage', data: { target }})
45
+ }
46
+ return {
47
+ CallExpression(node) {
48
+ if (node.callee.type != 'MemberExpression') return
49
+ if (node.callee.property?.name == 'addEventListener'
50
+ && node.arguments[0]?.value == 'message'
51
+ && isMessageTarget(node.callee.object)
52
+ ) checkPostMessageHandler(node, node.arguments[1], node.callee.object.name)
53
+ if (disallowWildcard && node.callee.property?.name == 'postMessage') {
54
+ const target = node.arguments[1]
55
+ if (target?.type == 'Literal' && target.value == '*')
56
+ context.report({ node: target, messageId: 'wildcardTarget' })
57
+ }
58
+ },
59
+ AssignmentExpression(node) {
60
+ if (node.left.type == 'MemberExpression'
61
+ && node.left.property?.name == 'onmessage'
62
+ && isMessageTarget(node.left.object)
63
+ ) checkPostMessageHandler(node, node.right, node.left.object.name)
64
+ }
65
+ }
66
+ }
67
+ }
@@ -1,10 +1,11 @@
1
- import { allAPIs, supportsAPI } from '../data/apis.js'
1
+ import api from '../utils/api.js'
2
+ import metadata from '../utils/metadata.js'
2
3
 
3
- import { extractMetadata } from '../utils.js'
4
+ import apis from '../data/apis.js'
5
+ import headers from '../data/headers.js'
4
6
 
5
- const managers = ['tampermonkey', 'violentmonkey', 'greasemonkey', 'scriptcat', 'adguard'],
6
- specialGrants = new Set(['none', 'unsafeWindow', 'window.close', 'window.focus', 'window.onurlchange']),
7
- allGrants = new Set([...allAPIs, ...specialGrants]),
7
+ const specialGrants = new Set(['none', 'unsafeWindow', 'window.close', 'window.focus', 'window.onurlchange']),
8
+ allGrants = new Set([...apis.all, ...specialGrants]),
8
9
  sortedGrants = new Map(Array.from(allGrants, grant => [grant.toLowerCase(), grant]))
9
10
 
10
11
  export default {
@@ -20,8 +21,9 @@ export default {
20
21
  properties: {
21
22
  target: {
22
23
  anyOf: [
23
- { type: 'string', enum: ['all', ...managers] },
24
- { type: 'array', items: { type: 'string', enum: managers }, minItems: 1, uniqueItems: true }
24
+ { type: 'string', enum: ['all', ...headers.managers] },
25
+ { type: 'array', items: { type: 'string', enum: headers.managers },
26
+ minItems: 1, uniqueItems: true }
25
27
  ],
26
28
  description: 'Manager(s) whose grants to validate against (default: all)'
27
29
  },
@@ -51,7 +53,7 @@ export default {
51
53
  allowedGrants = new Set(Array.isArray(rawAllowed) ? rawAllowed : [rawAllowed])
52
54
  return {
53
55
  Program() {
54
- const metaEntries = extractMetadata(context.sourceCode),
56
+ const metaEntries = metadata.extract(context.sourceCode),
55
57
  grantEntries = metaEntries.filter(entry => entry.key == 'grant')
56
58
  for (const grant of grantEntries) {
57
59
  const grantName = grant.value.trim()
@@ -67,9 +69,9 @@ export default {
67
69
  continue
68
70
  }
69
71
  if (!targets.includes('all')
70
- && !targets.some(manager => supportsAPI({ manager, api: grantName }))
72
+ && !targets.some(manager => api.supports({ manager, api: grantName }))
71
73
  ) {
72
- const supporting = managers.filter(manager => supportsAPI({ manager, api: grantName }))
74
+ const supporting = headers.managers.filter(manager => api.supports({ manager, api: grantName }))
73
75
  context.report({ node: grant.comment, messageId: 'unsupportedGrant',
74
76
  data: { grant: grantName, target: targets.join(', '), supporting: supporting.join(', ') }})
75
77
  }
@@ -1,12 +1,11 @@
1
- import { managerHeaders, managers, localizedHeaders, validHeaders } from '../data/headers.js'
2
-
3
- import { extractMetadata } from '../utils.js'
1
+ import metadata from '../utils/metadata.js'
2
+ import headers from '../data/headers.js'
4
3
 
5
4
  function parseAllowedPattern(entry) {
6
5
  if (typeof entry != 'string' || !entry.startsWith('/')) return null
7
6
  const lastSlash = entry.lastIndexOf('/')
8
7
  if (lastSlash == 0) return null
9
- try { /* eslint-disable-next-line security/detect-non-literal-regexp */
8
+ try { /* oxlint-disable-next-line security/detect-non-literal-regexp */
10
9
  return new RegExp(entry.slice(1, lastSlash), entry.slice(lastSlash +1))
11
10
  } catch { return null }
12
11
  }
@@ -24,8 +23,9 @@ export default {
24
23
  properties: {
25
24
  target: {
26
25
  anyOf: [
27
- { type: 'string', enum: ['all', ...managers] },
28
- { type: 'array', items: { type: 'string', enum: managers }, minItems: 1, uniqueItems: true }
26
+ { type: 'string', enum: ['all', ...headers.managers] },
27
+ { type: 'array', items: {
28
+ type: 'string', enum: headers.managers }, minItems: 1, uniqueItems: true }
29
29
  ],
30
30
  description: 'Manager(s) whose headers to validate against (default: all)'
31
31
  },
@@ -50,8 +50,8 @@ export default {
50
50
  allowedList = [options.allowedHeaders ?? []].flat(),
51
51
  allowedExact = new Set(),
52
52
  allowedRegexes = [],
53
- validSet = targets.includes('all') ? validHeaders.get('all')
54
- : new Set(targets.flatMap(target => [...validHeaders.get(target)]))
53
+ validSet = targets.includes('all') ? headers.valid.get('all')
54
+ : new Set(targets.flatMap(target => [...headers.valid.get(target)]))
55
55
  for (const entry of allowedList) {
56
56
  if (entry instanceof RegExp) allowedRegexes.push(entry)
57
57
  else if (typeof entry == 'string') {
@@ -62,13 +62,13 @@ export default {
62
62
  }
63
63
  return {
64
64
  Program() {
65
- const metaEntries = extractMetadata(context.sourceCode)
65
+ const metaEntries = metadata.extract(context.sourceCode)
66
66
  for (const entry of metaEntries) {
67
67
  if (allowedExact.has(entry.key)) continue
68
68
  if (allowedRegexes.some(re => re.test(entry.key))) continue
69
- if (localizedHeaders.includes(entry.key.split(':')[0])) continue
69
+ if (headers.localized.includes(entry.key.split(':')[0])) continue
70
70
  if (validSet.has(entry.key)) continue
71
- const supporting = managerHeaders.get(entry.key)
71
+ const supporting = headers.manager.get(entry.key)
72
72
  if (supporting)
73
73
  context.report({ node: entry.comment, messageId: 'unsupportedHeader',
74
74
  data: { header: entry.key, target: targets.join(', '), supporting: supporting.join(', ') }})
@@ -1,9 +1,7 @@
1
- import { gmLegacyAPIs, gmModernAPIs, scAPIs } from '../data/apis.js'
1
+ import metadata from '../utils/metadata.js'
2
+ import apis from '../data/apis.js'
2
3
 
3
- import { extractMetadata } from '../utils.js'
4
-
5
- const apis = new Set([...gmLegacyAPIs, ...gmModernAPIs, ...scAPIs]),
6
- grantlessAPIs = new Set(['GM_info', 'GM.info']) // always available, no grant required
4
+ apis.grantless = new Set(['GM_info', 'GM.info'])
7
5
 
8
6
  export default {
9
7
  meta: {
@@ -19,7 +17,7 @@ export default {
19
17
 
20
18
  create(context) {
21
19
  const usedAPIs = new Map()
22
- function record(name, node) { if (apis.has(name) && !usedAPIs.has(name)) usedAPIs.set(name, node) }
20
+ function record(name, node) { if (apis.all.has(name) && !usedAPIs.has(name)) usedAPIs.set(name, node) }
23
21
  return {
24
22
  Identifier(node) { record(node.name, node) },
25
23
  MemberExpression(node) {
@@ -37,12 +35,12 @@ export default {
37
35
  'Program:exit'() {
38
36
  if (!usedAPIs.size) return
39
37
  const declared = new Set(
40
- extractMetadata(context.sourceCode)
38
+ metadata.extract(context.sourceCode)
41
39
  .filter(entry => entry.key == 'grant')
42
40
  .map(entry => entry.value.trim())
43
41
  )
44
42
  for (const [api, node] of usedAPIs) {
45
- if (grantlessAPIs.has(api) || declared.has(api)) continue
43
+ if (apis.grantless.has(api) || declared.has(api)) continue
46
44
  context.report({ node, messageId: 'missingGrant', data: { api }})
47
45
  }
48
46
  }
@@ -1,7 +1,9 @@
1
- import { extractMetadata, resolveTemplate } from '../utils.js'
1
+ import metadata from '../utils/metadata.js'
2
+ import template from '../utils/template.js'
2
3
 
3
- const resourceGetterNames = new Set(['GM_getResourceText', 'GM_getResourceURL']),
4
- re = { specialChars: /[.*+?^${}()|[\]\\]/g, whitespace: /\s+/ }
4
+ import regex from '../data/regex.js'
5
+
6
+ const resourceGetterNames = new Set(['GM_getResourceText', 'GM_getResourceURL'])
5
7
 
6
8
  export default {
7
9
  meta: {
@@ -24,12 +26,12 @@ export default {
24
26
  if (arg?.type == 'Literal' && typeof arg.value == 'string')
25
27
  usedRefs.push({ names: [arg.value], pattern: null, node })
26
28
  else if (arg?.type == 'TemplateLiteral') {
27
- const resolved = resolveTemplate(arg)
29
+ const resolved = template.resolve(arg)
28
30
  if (resolved) usedRefs.push({ names: resolved, pattern: null, node })
29
31
  else usedRefs.push({
30
32
  names: null,
31
- pattern: new RegExp( // eslint-disable-line security/detect-non-literal-regexp
32
- `^${arg.quasis.map(q => q.value.raw.replace(re.specialChars, '\\$&')).join('.*')}$`),
33
+ pattern: new RegExp( // oxlint-disable-line security/detect-non-literal-regexp
34
+ `^${arg.quasis.map(q => q.value.raw.replace(regex.specialChars, '\\$&')).join('.*')}$`),
33
35
  node
34
36
  })
35
37
  }
@@ -42,9 +44,9 @@ export default {
42
44
  'Program:exit'() {
43
45
  if (!usedRefs.length) return
44
46
  const declared = new Set(
45
- extractMetadata(context.sourceCode)
47
+ metadata.extract(context.sourceCode)
46
48
  .filter(entry => entry.key == 'resource')
47
- .map(entry => entry.value.trim().split(re.whitespace)[0])
49
+ .map(entry => entry.value.trim().split(regex.whitespace.all)[0])
48
50
  )
49
51
  for (const ref of usedRefs) {
50
52
  if (ref.names) {
@@ -0,0 +1,81 @@
1
+ import apis from '../data/apis.js'
2
+ import headers from '../data/headers.js'
3
+ import regex from '../data/regex.js'
4
+
5
+ const re = { connect: /^\s*\/\/\s*@connect\s+(\S+)/, grant: /^\s*\/\/\s*@grant\s+(\S+)/ }
6
+
7
+ export default {
8
+ meta: {
9
+ type: 'problem',
10
+ docs: {
11
+ description: 'Flags security-sensitive `@grant` values and overbroad `@connect` patterns',
12
+ category: 'Possible Errors',
13
+ recommended: false,
14
+ security: true
15
+ },
16
+ defaultOptions: [{ target: 'all', allowedGrants: [], allowBroadConnect: false }],
17
+ schema: [{
18
+ type: 'object',
19
+ properties: {
20
+ target: {
21
+ oneOf: [
22
+ { type: 'string', enum: ['all', ...headers.managers], description: 'Single manager (or "all")' },
23
+ { type: 'array', items: { type: 'string', enum: headers.managers },
24
+ description: 'Multiple managers' }
25
+ ],
26
+ description: 'Manager(s) whose sensitive-grant set to check against'
27
+ },
28
+ allowedGrants: {
29
+ oneOf: [
30
+ { type: 'string', description: 'Single sensitive grant to allow' },
31
+ { type: 'array', items: { type: 'string' }, description: 'Multiple sensitive grants to allow' }
32
+ ],
33
+ description: 'Sensitive grants to explicitly allow'
34
+ },
35
+ allowBroadConnect: { type: 'boolean', description: 'Permit @connect * and wildcard-domain patterns' }
36
+ },
37
+ additionalProperties: false
38
+ }],
39
+ messages: {
40
+ sensitiveGrant: '`@grant {{grant}}` is security-sensitive. Review whether it\'s needed'
41
+ + ' or add it to `allowedGrants`.',
42
+ broadConnect: '`@connect {{pattern}}` allows `GM_xmlhttpRequest` to reach arbitrary hosts.'
43
+ + ' Scope it to specific hosts or enable `allowBroadConnect`.'
44
+ }
45
+ },
46
+
47
+ create(context) {
48
+ const opts = context.options[0] ?? {},
49
+ target = opts.target ?? 'all',
50
+ allowBroadConnect = opts.allowBroadConnect === true,
51
+ allowed = new Set(typeof opts.allowedGrants == 'string' ? [opts.allowedGrants]
52
+ : opts.allowedGrants ?? []),
53
+ sourceCode = context.sourceCode ?? context.getSourceCode(),
54
+ managers = target == 'all' ? [...apis.managerSensitive.keys()]
55
+ : Array.isArray(target) ? target : [target]
56
+ const isSensitive = grant => apis.sensitive.has(grant)
57
+ || managers.some(mgr => apis.managerSensitive.get(mgr)?.has(grant))
58
+ const report = (line, messageId, data, col, len) => context.report({
59
+ loc: { start: { line, column: col }, end: { line, column: col + len }}, messageId, data })
60
+ return {
61
+ Program() {
62
+ const match = sourceCode.text.match(regex.meta.block) ; if (!match) return
63
+ const lines = match[1].split(regex.newline),
64
+ baseLine = sourceCode.text.slice(0, match.index).split(regex.newline).length,
65
+ lineData = lines.map(line => ({
66
+ grant: line.match(re.grant)?.[1],
67
+ connect: line.match(re.connect)?.[1]
68
+ })),
69
+ hasXHR = lineData.some(d => d.grant == 'GM_xmlhttpRequest' || d.grant == 'GM.xmlHttpRequest')
70
+ for (const [i, { grant, connect }] of lineData.entries()) {
71
+ const lineNo = baseLine + i +1,
72
+ raw = lines[i] // oxlint-disable-line security/detect-object-injection
73
+ if (grant && !allowed.has(grant) && isSensitive(grant))
74
+ report(lineNo, 'sensitiveGrant', { grant }, raw.indexOf(grant), grant.length)
75
+ if (connect && hasXHR && !allowBroadConnect && headers.broadConnect.has(connect))
76
+ report(lineNo, 'broadConnect', { pattern: connect }, raw.indexOf(connect), connect.length)
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
@@ -1,4 +1,4 @@
1
- import { extractMetadata } from '../utils.js'
1
+ import metadata from '../utils/metadata.js'
2
2
 
3
3
  export default {
4
4
  meta: {
@@ -30,7 +30,7 @@ export default {
30
30
  },
31
31
  'Program:exit'() {
32
32
  const sourceCode = context.sourceCode,
33
- metaEntries = extractMetadata(sourceCode),
33
+ metaEntries = metadata.extract(sourceCode),
34
34
  grants = metaEntries.filter(entry => entry.key == 'grant')
35
35
  if (!grants.length) return
36
36
  for (const grant of grants) {
@@ -1,7 +1,9 @@
1
- import { extractMetadata, resolveTemplate } from '../utils.js'
1
+ import metadata from '../utils/metadata.js'
2
+ import template from '../utils/template.js'
2
3
 
3
- const resourceGetterNames = new Set(['GM_getResourceText', 'GM_getResourceURL']),
4
- re = { specialChars: /[.*+?^${}()|[\]\\]/g, whitespace: /\s+/ }
4
+ import regex from '../data/regex.js'
5
+
6
+ const resourceGetterNames = new Set(['GM_getResourceText', 'GM_getResourceURL'])
5
7
 
6
8
  export default {
7
9
  meta: {
@@ -25,19 +27,19 @@ export default {
25
27
  if (arg?.type == 'Literal' && typeof arg.value == 'string')
26
28
  usedResources.add(arg.value)
27
29
  else if (arg?.type == 'TemplateLiteral') {
28
- const resolved = resolveTemplate(arg)
30
+ const resolved = template.resolve(arg)
29
31
  if (resolved)
30
32
  for (const name of resolved) usedResources.add(name)
31
- else usedPatterns.push(new RegExp( // eslint-disable-line security/detect-non-literal-regexp
32
- `^${arg.quasis.map(quasi => quasi.value.raw.replace(re.specialChars, '\\$&')).join('.*')}$`))
33
+ else usedPatterns.push(new RegExp( // oxlint-disable-line security/detect-non-literal-regexp
34
+ `^${arg.quasis.map(quasi => quasi.value.raw.replace(regex.specialChars, '\\$&')).join('.*')}$`))
33
35
  }
34
36
  },
35
37
  'Program:exit'() {
36
- const metaEntries = extractMetadata(context.sourceCode),
38
+ const metaEntries = metadata.extract(context.sourceCode),
37
39
  resources = metaEntries.filter(entry => entry.key == 'resource')
38
40
  if (!resources.length) return
39
41
  for (const res of resources) {
40
- const name = res.value.trim().split(re.whitespace)[0]
42
+ const name = res.value.trim().split(regex.whitespace.all)[0]
41
43
  if (usedResources.has(name) || usedPatterns.some(patt => patt.test(name))) continue
42
44
  context.report({ node: res.comment, messageId: 'unusedResource', data: { name }})
43
45
  }
@@ -0,0 +1,67 @@
1
+ const re = { appendMethod: /^append(?:Child)?$/ }
2
+
3
+ function isCreateStyle(node) {
4
+ return node?.type == 'CallExpression'
5
+ && node.callee?.property?.name == 'createElement'
6
+ && node.arguments[0]?.value == 'style'
7
+ }
8
+ function isObjectAssign(node) {
9
+ return node?.type == 'CallExpression'
10
+ && node.callee?.type == 'MemberExpression'
11
+ && node.callee.object?.name == 'Object'
12
+ && node.callee.property?.name == 'assign'
13
+ }
14
+
15
+ export default {
16
+ meta: {
17
+ type: 'suggestion',
18
+ docs: {
19
+ description: 'Prefers `GM_addStyle()` or a shadow root over manual `<style>` injection',
20
+ category: 'Best Practices'
21
+ },
22
+ schema: [],
23
+ messages: {
24
+ preferGmAddStyle: 'Use `GM_addStyle()` or inject into a `shadowRoot` '
25
+ + 'instead of manually appending a `<style>` element.'
26
+ }
27
+ },
28
+
29
+ create(context) {
30
+ const styleElems = new Set(), docAliases = new Set()
31
+ function isDocumentRef(node) {
32
+ return node?.type == 'Identifier'
33
+ && (node.name == 'document' || docAliases.has(node.name))
34
+ }
35
+ function isDocumentMember(node) {
36
+ return node?.type == 'MemberExpression' && isDocumentRef(node.object)
37
+ }
38
+ function isStyleTarget(node) {
39
+ if (isCreateStyle(node)) return true
40
+ if (node?.type == 'Identifier' && styleElems.has(node.name)
41
+ || isObjectAssign(node) && isCreateStyle(node.arguments[0])
42
+ || isObjectAssign(node)
43
+ && node.arguments[0]?.type == 'Identifier'
44
+ && styleElems.has(node.arguments[0].name)
45
+ ) return true
46
+ return false
47
+ }
48
+ return {
49
+ VariableDeclarator(node) {
50
+ if (node.id.type != 'Identifier') return
51
+ if (isStyleTarget(node.init))
52
+ styleElems.add(node.id.name)
53
+ if (node.init?.type == 'Identifier' && isDocumentRef(node.init))
54
+ docAliases.add(node.id.name)
55
+ },
56
+ CallExpression(node) {
57
+ if (node.callee.type != 'MemberExpression') return
58
+ const method = node.callee.property.name
59
+ if (!re.appendMethod.test(method) || !isDocumentMember(node.callee.object))
60
+ return
61
+ for (const arg of node.arguments)
62
+ if (isStyleTarget(arg))
63
+ context.report({ node: arg, messageId: 'preferGmAddStyle' })
64
+ }
65
+ }
66
+ }
67
+ }
@@ -1,4 +1,4 @@
1
- import { extractMetadata } from '../utils.js'
1
+ import metadata from '../utils/metadata.js'
2
2
 
3
3
  export default {
4
4
  meta: {
@@ -6,7 +6,8 @@ export default {
6
6
  docs: {
7
7
  description: 'Prefer @match over @include (see https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline)',
8
8
  category: 'Best Practices',
9
- recommended: true
9
+ recommended: true,
10
+ security: true
10
11
  },
11
12
  schema: [],
12
13
  messages: { preferMatch: 'Use @match instead of @include.' }
@@ -15,7 +16,7 @@ export default {
15
16
  create(context) {
16
17
  return {
17
18
  Program() {
18
- for (const entry of extractMetadata(context.sourceCode))
19
+ for (const entry of metadata.extract(context.sourceCode))
19
20
  if (entry.key == 'include')
20
21
  context.report({ node: entry.comment, messageId: 'preferMatch' })
21
22
  }
@@ -1,12 +1,11 @@
1
- import { extractMetadata } from '../utils.js'
1
+ import metadata from '../utils/metadata.js'
2
2
 
3
3
  export default {
4
4
  meta: {
5
5
  type: 'problem',
6
6
  docs: {
7
7
  description: 'Require @homepage and @homepageURL to be used together',
8
- category: 'Best Practices',
9
- recommended: false
8
+ category: 'Best Practices'
10
9
  },
11
10
  fixable: 'code',
12
11
  schema: [],
@@ -16,7 +15,7 @@ export default {
16
15
  create(context) {
17
16
  return {
18
17
  Program() {
19
- const metaEntries = extractMetadata(context.sourceCode),
18
+ const metaEntries = metadata.extract(context.sourceCode),
20
19
  homepageEntry = metaEntries.find(entry => entry.key == 'homepage'),
21
20
  homepageURLentry = metaEntries.find(entry => entry.key == 'homepageURL')
22
21
  if (homepageEntry && !homepageURLentry)
@@ -0,0 +1,27 @@
1
+ import regex from '../data/regex.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Requires a `@license` metadata field',
8
+ category: 'Possible Errors',
9
+ recommended: false
10
+ },
11
+ schema: [],
12
+ messages: {
13
+ missingLicense: 'No `@license` field found. Add one (e.g. `@license MIT`).'
14
+ }
15
+ },
16
+ create(context) {
17
+ const sourceCode = context.sourceCode ?? context.getSourceCode()
18
+ return {
19
+ Program() {
20
+ const match = sourceCode.text.match(regex.meta.block)
21
+ if (!match) return
22
+ if (!regex.meta.license.test(match[1]))
23
+ context.report({ loc: { line: 1, column: 0 }, messageId: 'missingLicense' })
24
+ }
25
+ }
26
+ }
27
+ }