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.
- package/README.md +776 -68
- package/docs/LICENSE.md +1 -1
- package/package.json +53 -43
- package/src/data/apis.js +56 -53
- package/src/data/headers.js +65 -31
- package/src/data/regex.js +14 -0
- package/src/index.d.ts +34 -3
- package/src/index.js +5 -80
- package/src/rules/meta-order.js +5 -6
- package/src/rules/meta-spacing.js +11 -10
- package/src/rules/no-duplicate-keys.js +2 -2
- package/src/rules/no-insecure-fetch.js +108 -0
- package/src/rules/no-insecure-messaging.js +67 -0
- package/src/rules/no-invalid-grants.js +12 -10
- package/src/rules/no-invalid-headers.js +11 -11
- package/src/rules/no-missing-grants.js +6 -8
- package/src/rules/no-missing-resources.js +10 -8
- package/src/rules/no-sensitive-grants.js +81 -0
- package/src/rules/no-unused-grants.js +2 -2
- package/src/rules/no-unused-resources.js +10 -8
- package/src/rules/prefer-gm-add-style.js +67 -0
- package/src/rules/prefer-match.js +4 -3
- package/src/rules/require-homepage-pair.js +3 -4
- package/src/rules/require-license-header.js +27 -0
- package/src/rules/require-matching-localized-fields.js +2 -2
- package/src/rules/require-required-fields.js +2 -2
- package/src/rules/require-space-before-meta-key.js +4 -5
- package/src/rules/require-sri-hashing.js +3 -3
- package/src/rules/require-update-download-pair.js +3 -4
- package/src/rules/require-user-js-filename.js +2 -2
- package/src/rules/valid-field-values.js +7 -7
- package/src/rules/valid-icon-headers.js +74 -0
- package/src/rules/valid-license-header.js +73 -0
- package/src/rules/valid-url-headers.js +114 -0
- package/src/rules/valid-version-header.js +83 -0
- package/src/utils/api.js +11 -0
- package/src/utils/array.js +1 -0
- package/src/utils/deprecations.js +76 -0
- package/src/utils/init.js +106 -0
- package/src/utils/license.js +56 -0
- package/src/utils/metadata.js +31 -0
- package/src/utils/pkg.js +21 -0
- package/src/utils/rules.js +7 -0
- package/src/utils/template.js +21 -0
- 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
|
|
1
|
+
import api from '../utils/api.js'
|
|
2
|
+
import metadata from '../utils/metadata.js'
|
|
2
3
|
|
|
3
|
-
import
|
|
4
|
+
import apis from '../data/apis.js'
|
|
5
|
+
import headers from '../data/headers.js'
|
|
4
6
|
|
|
5
|
-
const
|
|
6
|
-
|
|
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 },
|
|
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 =
|
|
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 =>
|
|
72
|
+
&& !targets.some(manager => api.supports({ manager, api: grantName }))
|
|
71
73
|
) {
|
|
72
|
-
const supporting = managers.filter(manager =>
|
|
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
|
|
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 { /*
|
|
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: {
|
|
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') ?
|
|
54
|
-
: new Set(targets.flatMap(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 =
|
|
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 (
|
|
69
|
+
if (headers.localized.includes(entry.key.split(':')[0])) continue
|
|
70
70
|
if (validSet.has(entry.key)) continue
|
|
71
|
-
const supporting =
|
|
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
|
|
1
|
+
import metadata from '../utils/metadata.js'
|
|
2
|
+
import apis from '../data/apis.js'
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1
|
+
import metadata from '../utils/metadata.js'
|
|
2
|
+
import template from '../utils/template.js'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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( //
|
|
32
|
-
`^${arg.quasis.map(q => q.value.raw.replace(
|
|
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
|
-
|
|
47
|
+
metadata.extract(context.sourceCode)
|
|
46
48
|
.filter(entry => entry.key == 'resource')
|
|
47
|
-
.map(entry => entry.value.trim().split(
|
|
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
|
|
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 =
|
|
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
|
|
1
|
+
import metadata from '../utils/metadata.js'
|
|
2
|
+
import template from '../utils/template.js'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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( //
|
|
32
|
-
`^${arg.quasis.map(quasi => quasi.value.raw.replace(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
+
}
|