webstore-lint 1.0.20
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/LICENSE +21 -0
- package/README.md +356 -0
- package/action/report.mjs +222 -0
- package/action.yml +84 -0
- package/bin/webstore-lint.mjs +304 -0
- package/data/policy.json +652 -0
- package/package.json +48 -0
- package/src/audit.mjs +544 -0
- package/src/lint.mjs +125 -0
- package/src/privacy.mjs +218 -0
- package/src/rules.mjs +1549 -0
- package/src/scan.mjs +705 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webstore-lint",
|
|
3
|
+
"version": "1.0.20",
|
|
4
|
+
"description": "Check an unpacked Chrome extension against the Chrome Web Store program policies before you submit it. Every finding cites Google's own notification ID and verbatim policy text. Covers the 1 August 2026 policy updates.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"chrome-extension",
|
|
7
|
+
"chrome-web-store",
|
|
8
|
+
"manifest-v3",
|
|
9
|
+
"mv3",
|
|
10
|
+
"lint",
|
|
11
|
+
"linter",
|
|
12
|
+
"policy",
|
|
13
|
+
"rejection",
|
|
14
|
+
"rejected",
|
|
15
|
+
"review",
|
|
16
|
+
"webextension",
|
|
17
|
+
"browser-extension",
|
|
18
|
+
"compliance",
|
|
19
|
+
"static-analysis"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"webstore-lint": "bin/webstore-lint.mjs"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/lint.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src",
|
|
32
|
+
"data",
|
|
33
|
+
"action",
|
|
34
|
+
"action.yml",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node test/lint.test.mjs && node test/action.test.mjs"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Circadian-agent/chrome-extension-rejection-lint.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/Circadian-agent/chrome-extension-rejection-lint"
|
|
48
|
+
}
|
package/src/audit.mjs
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// The permission ledger: for every permission the manifest asks for, what in the
|
|
2
|
+
// code actually needs it, and whether a narrower permission would have done.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS A SEPARATE PASS FROM THE RULES. A rule answers "is this a
|
|
5
|
+
// violation". Excessive permissions is not answerable that way, because the
|
|
6
|
+
// question Google actually asks is comparative: "If more than one permission
|
|
7
|
+
// could be used to implement a feature, you must request those with the least
|
|
8
|
+
// access to data or functionality." That is a claim about an alternative that
|
|
9
|
+
// was not taken, so it needs the call sites, not a yes or no.
|
|
10
|
+
//
|
|
11
|
+
// The Chrome Web Store dashboard makes the developer type a justification for
|
|
12
|
+
// every permission, and Purple Potassium is what comes back when those are thin.
|
|
13
|
+
// A justification is only as good as the evidence under it, so this pass exists
|
|
14
|
+
// to produce the evidence: permission -> the exact lines that require it.
|
|
15
|
+
//
|
|
16
|
+
// WHAT THIS DELIBERATELY WILL NOT DO: guess. Three separate honesty rules run
|
|
17
|
+
// through the file.
|
|
18
|
+
//
|
|
19
|
+
// 1. A permission with no entry in PERMISSION_API is reported UNKNOWN, never
|
|
20
|
+
// unused. Silence about a permission we do not model is correct; calling it
|
|
21
|
+
// unused because we have no pattern for it is a false accusation that gets
|
|
22
|
+
// a developer to delete something their extension needs.
|
|
23
|
+
// 2. Every narrowing suggestion carries the evidence that motivated it AND the
|
|
24
|
+
// condition that would make it wrong. A reviewer-facing claim we cannot
|
|
25
|
+
// support is worse than no claim.
|
|
26
|
+
// 3. Text findings are drawn from the packaged JavaScript only. Minified or
|
|
27
|
+
// bundled code defeats call-site analysis, and when that is detected the
|
|
28
|
+
// pass says so rather than reporting a confident zero.
|
|
29
|
+
|
|
30
|
+
import { isCode, codeView, largeWindows, grepLarge } from "./scan.mjs";
|
|
31
|
+
|
|
32
|
+
// Permission -> the namespaces whose presence proves it is used. THIS IS THE ONLY
|
|
33
|
+
// COPY, and it was not always: rules.mjs carried its own on the argument that it
|
|
34
|
+
// needs a boolean where this pass needs call sites. That is a difference between
|
|
35
|
+
// the two CONSUMERS, not between the two tables, and the tables drifted anyway -
|
|
36
|
+
// four permissions deep by the time anyone counted (s105, T-0421). The drift ran
|
|
37
|
+
// in the silent direction, which is why nobody noticed: a permission the rule does
|
|
38
|
+
// not model is read as "unknown" and produces no finding, so the tool simply could
|
|
39
|
+
// never flag activeTab, offscreen, sidePanel or declarativeNetRequest. Anything
|
|
40
|
+
// that can be answered once is shared - see codeView in scan.mjs.
|
|
41
|
+
export const PERMISSION_API = {
|
|
42
|
+
cookies: ["chrome.cookies", "browser.cookies"],
|
|
43
|
+
history: ["chrome.history", "browser.history"],
|
|
44
|
+
topSites: ["chrome.topSites", "browser.topSites"],
|
|
45
|
+
bookmarks: ["chrome.bookmarks", "browser.bookmarks"],
|
|
46
|
+
downloads: ["chrome.downloads", "browser.downloads"],
|
|
47
|
+
browsingData: ["chrome.browsingData", "browser.browsingData"],
|
|
48
|
+
management: ["chrome.management", "browser.management"],
|
|
49
|
+
tabs: ["chrome.tabs", "browser.tabs"],
|
|
50
|
+
webRequest: ["chrome.webRequest", "browser.webRequest"],
|
|
51
|
+
webNavigation: ["chrome.webNavigation", "browser.webNavigation"],
|
|
52
|
+
identity: ["chrome.identity", "browser.identity"],
|
|
53
|
+
storage: ["chrome.storage", "browser.storage"],
|
|
54
|
+
notifications: ["chrome.notifications", "browser.notifications"],
|
|
55
|
+
contextMenus: ["chrome.contextMenus", "browser.contextMenus"],
|
|
56
|
+
alarms: ["chrome.alarms", "browser.alarms"],
|
|
57
|
+
scripting: ["chrome.scripting", "browser.scripting"],
|
|
58
|
+
debugger: ["chrome.debugger", "browser.debugger"],
|
|
59
|
+
proxy: ["chrome.proxy", "browser.proxy"],
|
|
60
|
+
privacy: ["chrome.privacy", "browser.privacy"],
|
|
61
|
+
contentSettings: ["chrome.contentSettings", "browser.contentSettings"],
|
|
62
|
+
pageCapture: ["chrome.pageCapture", "browser.pageCapture"],
|
|
63
|
+
desktopCapture: ["chrome.desktopCapture", "browser.desktopCapture"],
|
|
64
|
+
idle: ["chrome.idle", "browser.idle"],
|
|
65
|
+
power: ["chrome.power", "browser.power"],
|
|
66
|
+
tts: ["chrome.tts", "browser.tts"],
|
|
67
|
+
activeTab: [], // granted by user gesture; no namespace of its own
|
|
68
|
+
offscreen: ["chrome.offscreen", "browser.offscreen"],
|
|
69
|
+
sidePanel: ["chrome.sidePanel", "browser.sidePanel"],
|
|
70
|
+
declarativeNetRequest: ["chrome.declarativeNetRequest", "browser.declarativeNetRequest"],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Permissions that HAVE no namespace, as opposed to permissions whose namespace
|
|
74
|
+
// we failed to find. The distinction is the whole safety of the unused rule: an
|
|
75
|
+
// empty pattern list makes namespaceUsed answer false, and false there means
|
|
76
|
+
// "declared but never used", which is a FAIL telling a developer to delete it.
|
|
77
|
+
//
|
|
78
|
+
// activeTab is granted for the duration of a user gesture and exposes nothing of
|
|
79
|
+
// its own, so it has no call sites BY CONSTRUCTION and no manifest key either.
|
|
80
|
+
// Flagging it would be worse than a merely wrong finding: this same file's
|
|
81
|
+
// narrowing advice tells people to move from `tabs` to activeTab and from broad
|
|
82
|
+
// hosts to `scripting + activeTab`, so the tool would recommend a permission on
|
|
83
|
+
// one line and demand its deletion on another. Both halves read this set, so
|
|
84
|
+
// neither can start disagreeing about it.
|
|
85
|
+
export const NO_NAMESPACE_PERMISSIONS = new Map([
|
|
86
|
+
["activeTab", "activeTab is granted by a user gesture and has no namespace of its own, so it has no call sites by construction. It is the narrow answer, not a finding."],
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Does the packaged code use this permission's namespace?
|
|
90
|
+
//
|
|
91
|
+
// A LITERAL `chrome.storage` TEST IS NOT ENOUGH, and a real shipped bundle is
|
|
92
|
+
// what proved it (s098). immersive-translate's `dist/chrome` package reaches the
|
|
93
|
+
// same APIs as `Ne.storage`, `je.storage` and `v.webRequest`, because aliasing a
|
|
94
|
+
// repeated global is the first thing any minifier does. We reported storage,
|
|
95
|
+
// contextMenus and webRequest as "declared but never used" - at severity FAIL -
|
|
96
|
+
// on an extension that uses all three. Nothing about that output looked wrong.
|
|
97
|
+
//
|
|
98
|
+
// THE ASYMMETRY DECIDES THE DESIGN, and it runs the opposite way to the rest of
|
|
99
|
+
// this linter. Elsewhere a false pass is the cheap error. Here a missed unused
|
|
100
|
+
// permission is only silence, while a false one tells a developer to delete a
|
|
101
|
+
// permission their extension needs - so every ambiguous form resolves toward
|
|
102
|
+
// USED. The matcher is deliberately looser than a parser would be: a property
|
|
103
|
+
// access on ANY identifier counts, not only one on `chrome`. That costs real
|
|
104
|
+
// recall - `.tabs` on an unrelated object is enough to keep us quiet - and the
|
|
105
|
+
// trade is taken knowingly, because the commonest true positive is a permission
|
|
106
|
+
// left behind when a feature was deleted, and that leaves no reference of any
|
|
107
|
+
// kind for any of these patterns to match.
|
|
108
|
+
export function namespaceUsed(code, apis) {
|
|
109
|
+
if (!apis?.length) return false;
|
|
110
|
+
if (apis.some((a) => code.includes(a))) return true; // chrome.storage, browser.storage
|
|
111
|
+
const leaf = apis[0].split(".").pop();
|
|
112
|
+
if (!leaf) return false;
|
|
113
|
+
const n = leaf.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
return (
|
|
115
|
+
// `\??\.` because optional chaining defeats a plain dot, and it is not an
|
|
116
|
+
// exotic form: refined-github probes `globalThis.chrome?.contextMenus`, and
|
|
117
|
+
// both this pattern and the literal `chrome.contextMenus` test missed it.
|
|
118
|
+
new RegExp(`[\\w$)\\]]\\s*\\??\\.\\s*${n}\\b`).test(code) || // Ne.storage, chrome?.storage, getApi().storage
|
|
119
|
+
new RegExp(`\\[\\s*["'\`]${n}["'\`]\\s*\\]`).test(code) || // chrome["storage"]
|
|
120
|
+
new RegExp(`\\{[^{}]*\\b${n}\\b[^{}]*\\}\\s*=`).test(code) // const { storage } = chrome
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Imports of a BARE module specifier - `import x from "clsx"` rather than
|
|
125
|
+
// "./util.js". Chrome resolves no such name, so a directory containing one is
|
|
126
|
+
// pre-build source and not a package that could ever load.
|
|
127
|
+
//
|
|
128
|
+
// THIS IS THE OTHER HALF OF "WE DID NOT READ ALL THE CODE", and it is the one
|
|
129
|
+
// that survived every earlier fix. refined-github's `source/` declares
|
|
130
|
+
// scripting and alarms and calls neither - because the calls are inside
|
|
131
|
+
// `webext-dynamic-content-scripts` and friends, which live in node_modules and
|
|
132
|
+
// are not in the repository at all. We failed it for permissions its own
|
|
133
|
+
// dependencies use. T-0403 already tells the reader in the README to point this
|
|
134
|
+
// tool at dist/ or .output/chrome-mv3; prose next to a tool does not travel, so
|
|
135
|
+
// the tool now checks.
|
|
136
|
+
// THE STATEMENT MUST BE ANCHORED, and the first version was not - it looked for
|
|
137
|
+
// the word `from` next to a quote anywhere on a line. Published as v1.0.4 and
|
|
138
|
+
// caught by running the published artifact against a real bundle, which is the
|
|
139
|
+
// second time that step has found what the local tree hid:
|
|
140
|
+
//
|
|
141
|
+
// return ["from", "to"].forEach((a) => {
|
|
142
|
+
//
|
|
143
|
+
// matched `from` inside a STRING, took the closing quote of "from" and the
|
|
144
|
+
// opening quote of "to" as its delimiters, and reported a module named `, `.
|
|
145
|
+
// It fired on immersive-translate's shipped dist/chrome - the built output this
|
|
146
|
+
// rule exists to tell people to use. A rule that fires on the directory it is
|
|
147
|
+
// recommending is worse than no rule.
|
|
148
|
+
//
|
|
149
|
+
// So a match must begin a statement: `import` or `export` at the head of a line.
|
|
150
|
+
// Multi-line import lists are still caught (the tail is bounded), and a dynamic
|
|
151
|
+
// `import()` is deliberately NOT matched - a bare dynamic import of a package
|
|
152
|
+
// name barely exists in extension source, and every form it takes here would be
|
|
153
|
+
// relative or a URL, both of which are excluded anyway.
|
|
154
|
+
export function bareImports(files) {
|
|
155
|
+
const out = [];
|
|
156
|
+
const pats = [
|
|
157
|
+
/^[ \t]*(?:import|export)\b[^;'"]{0,300}?\bfrom\s*["']([^"']+)["']/gm, // import x from "clsx"
|
|
158
|
+
/^[ \t]*import\s*["']([^"']+)["']/gm, // import "side-effect"
|
|
159
|
+
];
|
|
160
|
+
for (const f of files) {
|
|
161
|
+
if (!isCode(f) || !/\.(js|mjs|cjs|ts|jsx|tsx)$/i.test(f.path)) continue;
|
|
162
|
+
for (const pat of pats) {
|
|
163
|
+
pat.lastIndex = 0;
|
|
164
|
+
for (const m of f.text.matchAll(pat)) {
|
|
165
|
+
const spec = m[1];
|
|
166
|
+
// Relative and absolute paths resolve; anything with a scheme is a URL,
|
|
167
|
+
// which is a different finding (remote-code) and not this one.
|
|
168
|
+
if (/^[./]/.test(spec) || spec.includes(":")) continue;
|
|
169
|
+
const line = f.text.slice(0, m.index).split("\n").length;
|
|
170
|
+
out.push({ file: f.path, line, spec, text: (f.lines[line - 1] || "").trim().slice(0, 160) });
|
|
171
|
+
if (out.length >= 25) return out;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// SOME PERMISSIONS ARE EARNED BY THE MANIFEST, WITH NO JAVASCRIPT AT ALL, and
|
|
179
|
+
// missing that produced a false "unused" on the two below - advice that would
|
|
180
|
+
// have broken the very feature the permission exists for.
|
|
181
|
+
//
|
|
182
|
+
// declarativeNetRequest is the headline case: the RECOMMENDED MV3 way to block
|
|
183
|
+
// requests is a static ruleset declared under `declarative_net_request`, and an
|
|
184
|
+
// extension doing exactly that touches no chrome.* namespace anywhere. We were
|
|
185
|
+
// telling ad blockers to delete the permission that does their blocking. sidePanel
|
|
186
|
+
// is the same shape - `side_panel.default_path` is a complete, working
|
|
187
|
+
// configuration on its own.
|
|
188
|
+
//
|
|
189
|
+
// Each entry returns the human description of the evidence, or null. A call site
|
|
190
|
+
// is better evidence when it exists, so this is only consulted when there is none.
|
|
191
|
+
export const MANIFEST_EVIDENCE = {
|
|
192
|
+
declarativeNetRequest: (m) => {
|
|
193
|
+
const rs = m.declarative_net_request?.rule_resources;
|
|
194
|
+
if (!Array.isArray(rs) || !rs.length) return null;
|
|
195
|
+
const paths = rs.map((r) => r?.path).filter(Boolean);
|
|
196
|
+
return `declarative_net_request.rule_resources declares ${rs.length} static ruleset(s)${paths.length ? ` (${paths.join(", ")})` : ""}. A static ruleset is the recommended MV3 way to block or modify requests and needs no JavaScript, so an absence of call sites is expected here rather than a finding.`;
|
|
197
|
+
},
|
|
198
|
+
sidePanel: (m) =>
|
|
199
|
+
m.side_panel?.default_path
|
|
200
|
+
? `side_panel.default_path is set to ${m.side_panel.default_path}. That is a complete side panel configuration on its own, so an absence of call sites is expected here rather than a finding.`
|
|
201
|
+
: null,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Which Chrome data-disclosure category a permission drags you into. Used to
|
|
205
|
+
// answer the Privacy practices tab, which is where Purple Nickel is decided.
|
|
206
|
+
const DISCLOSURE_CATEGORY = {
|
|
207
|
+
cookies: "Authentication information / website content",
|
|
208
|
+
history: "Web history",
|
|
209
|
+
topSites: "Web history",
|
|
210
|
+
bookmarks: "Personal communications or user-generated content",
|
|
211
|
+
browsingData: "Web history",
|
|
212
|
+
tabs: "Web history (tab URLs are browsing history)",
|
|
213
|
+
webNavigation: "Web history",
|
|
214
|
+
webRequest: "Web history and website content",
|
|
215
|
+
identity: "Personally identifiable information",
|
|
216
|
+
downloads: "User activity",
|
|
217
|
+
management: "User activity",
|
|
218
|
+
pageCapture: "Website content",
|
|
219
|
+
desktopCapture: "Website content",
|
|
220
|
+
debugger: "Website content",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const BROAD = new Set(["<all_urls>", "*://*/*", "http://*/*", "https://*/*", "*://*/", "file:///*"]);
|
|
224
|
+
|
|
225
|
+
// Find every line where a namespace is touched. Whole-file includes() cannot do
|
|
226
|
+
// this: it answers "somewhere" and a justification needs "here".
|
|
227
|
+
function callSites(files, needles) {
|
|
228
|
+
const hits = [];
|
|
229
|
+
for (const f of files) {
|
|
230
|
+
if (!isCode(f)) continue;
|
|
231
|
+
for (let i = 0; i < f.lines.length; i++) {
|
|
232
|
+
const line = f.lines[i];
|
|
233
|
+
for (const n of needles) {
|
|
234
|
+
if (line.includes(n)) {
|
|
235
|
+
hits.push({ file: f.path, line: i + 1, api: n, text: line.trim().slice(0, 160) });
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return hits;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Minified code defeats line-level evidence: a whole bundle on one line makes
|
|
245
|
+
// every call site look like the same place, and an absence of call sites in a
|
|
246
|
+
// bundle is not an absence in the source. Detected and declared, never assumed
|
|
247
|
+
// away - an audit that reports a confident zero over a bundle is the same class
|
|
248
|
+
// of error as a health check that passes on failure.
|
|
249
|
+
// THE THRESHOLD LIVES HERE ONCE. Two callers now ask "is this minified" - the
|
|
250
|
+
// files held in memory and the ones read through a window - and a copy of these
|
|
251
|
+
// numbers in the second one would be a copy that drifts. It matters more than it
|
|
252
|
+
// looks: this predicate is the only thing standing between a bundle and a FAIL
|
|
253
|
+
// telling someone to delete a permission their extension uses.
|
|
254
|
+
const isMinifiedShape = (longest, avg, total) => longest > 2000 || (avg > 300 && total > 20000);
|
|
255
|
+
|
|
256
|
+
export function looksMinified(files) {
|
|
257
|
+
const suspect = [];
|
|
258
|
+
for (const f of files) {
|
|
259
|
+
if (!isCode(f)) continue;
|
|
260
|
+
const longest = f.lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
261
|
+
const avg = f.text.length / Math.max(1, f.lines.length);
|
|
262
|
+
if (isMinifiedShape(longest, avg, f.text.length)) {
|
|
263
|
+
suspect.push({ file: f.path, longestLine: longest, avgLineLength: Math.round(avg) });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return suspect;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The same question about a file too big to hold, measured while streaming it
|
|
270
|
+
// rather than from a lines array that is never built.
|
|
271
|
+
//
|
|
272
|
+
// WHY IT IS NEEDED AT ALL, and it is the whole safety argument for reading these
|
|
273
|
+
// files: streaming lets the tool CONFIRM a permission is used, which only ever
|
|
274
|
+
// removes a finding. Removing the "we did not read everything" caveat is the
|
|
275
|
+
// other direction, and it turns a warning into a FAIL. Measured over the 82
|
|
276
|
+
// cached release packages: 17 permissions in 7 packages would have flipped to
|
|
277
|
+
// "declared but never used" - including contextMenus and idle on Bitwarden,
|
|
278
|
+
// which plainly uses both - and EVERY ONE of them is held back by this check,
|
|
279
|
+
// because every oversized file in those packages is a bundle with a line between
|
|
280
|
+
// 170,000 and 1,056,768 characters long. A minifier rewrites `chrome` to a
|
|
281
|
+
// one-letter local, so having READ the bytes is still not having seen the name.
|
|
282
|
+
export function looksMinifiedLarge(oversized) {
|
|
283
|
+
const suspect = [];
|
|
284
|
+
for (const f of oversized || []) {
|
|
285
|
+
if (!isCode(f)) continue;
|
|
286
|
+
let longest = 0, cur = 0, total = 0, newlines = 0, seen = 0;
|
|
287
|
+
for (const w of largeWindows(f)) {
|
|
288
|
+
// Each window overlaps the last, so only count what is new in it.
|
|
289
|
+
const fresh = w.raw.slice(seen);
|
|
290
|
+
seen = w.raw.length;
|
|
291
|
+
for (let i = 0; i < fresh.length; i++) {
|
|
292
|
+
total++;
|
|
293
|
+
if (fresh.charCodeAt(i) === 10) { newlines++; if (cur > longest) longest = cur; cur = 0; }
|
|
294
|
+
else cur++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (cur > longest) longest = cur;
|
|
298
|
+
const avg = total / Math.max(1, newlines + 1);
|
|
299
|
+
if (isMinifiedShape(longest, avg, total)) {
|
|
300
|
+
suspect.push({ file: f.path, longestLine: longest, avgLineLength: Math.round(avg) });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return suspect;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Call sites in the files too big to hold. Same literal needles as callSites
|
|
307
|
+
// above, so the two cannot disagree about what counts as a site; the evidence is
|
|
308
|
+
// a snippet rather than a whole line because on a bundle "the line" is the file.
|
|
309
|
+
export function largeCallSites(oversized, needles) {
|
|
310
|
+
if (!needles?.length) return [];
|
|
311
|
+
const esc = (n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
312
|
+
const re = new RegExp(needles.map(esc).join("|"));
|
|
313
|
+
return grepLarge(oversized, re, { filter: isCode, code: true })
|
|
314
|
+
.filter((h) => h.match)
|
|
315
|
+
.map((h) => ({ file: h.file, line: h.line, api: h.match, text: h.text }));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Does anything here need tabs beyond the one the user just acted on? Asked over
|
|
319
|
+
// the FILE TEXT, never one line.
|
|
320
|
+
//
|
|
321
|
+
// WHY, because this was a real bug and it is the second time this shape has cost
|
|
322
|
+
// us: the guard used to be one regex tested against a single line, so a
|
|
323
|
+
// chrome.tabs.query({...}) whose object a formatter wrapped across two lines
|
|
324
|
+
// stopped matching, and a background tab enumerator was advised to drop tabs for
|
|
325
|
+
// activeTab. Two packages differing ONLY in where a line break fell got opposite
|
|
326
|
+
// advice, and the wrong one breaks the extension. Note the brace class [^}] was
|
|
327
|
+
// always newline-safe; the defect was only ever the per-line application.
|
|
328
|
+
//
|
|
329
|
+
// The same expression also let an `active: true` anywhere on the line cancel an
|
|
330
|
+
// OBSERVER match, so the two questions are now asked separately.
|
|
331
|
+
function needsAllTabs(files) {
|
|
332
|
+
const hits = [];
|
|
333
|
+
const at = (f, index, text) => ({
|
|
334
|
+
file: f.path,
|
|
335
|
+
line: f.text.slice(0, index).split("\n").length,
|
|
336
|
+
api: "chrome.tabs",
|
|
337
|
+
text: text.replace(/\s+/g, " ").trim().slice(0, 160),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
for (const f of files) {
|
|
341
|
+
if (!isCode(f)) continue;
|
|
342
|
+
|
|
343
|
+
// Observers fire for tabs the user has never interacted with, so activeTab
|
|
344
|
+
// cannot carry them however the call is formatted.
|
|
345
|
+
for (const m of f.text.matchAll(
|
|
346
|
+
/\b(?:chrome|browser)\.tabs\.(?:onUpdated|onCreated|onRemoved|onActivated|onReplaced|getAllInWindow)\b/g,
|
|
347
|
+
)) {
|
|
348
|
+
hits.push(at(f, m.index, m[0]));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A query is narrowable only if it can be SHOWN to ask for the active tab.
|
|
352
|
+
// Anything we cannot read - a wrapped object, a variable, a spread - counts
|
|
353
|
+
// against the narrowing, because honesty rule 2 in this file's header says a
|
|
354
|
+
// claim we cannot support is worse than no claim, and the cost of being wrong
|
|
355
|
+
// here is a developer deleting a permission their extension needs.
|
|
356
|
+
for (const m of f.text.matchAll(/\b(?:chrome|browser)\.tabs\.query\s*\(\s*(\{[^}]*\})?/g)) {
|
|
357
|
+
const arg = m[1];
|
|
358
|
+
if (arg && /\bactive\s*:\s*true\b/.test(arg) && !/\.\.\./.test(arg)) continue;
|
|
359
|
+
hits.push(at(f, m.index, m[0]));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return hits;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- the narrowing analysis --------------------------------------------------
|
|
366
|
+
//
|
|
367
|
+
// Each entry states the alternative, the evidence that suggests it, and the
|
|
368
|
+
// condition under which the suggestion is WRONG. That last field is not padding:
|
|
369
|
+
// it is what makes the output usable in a reply to a reviewer, and what stops a
|
|
370
|
+
// developer deleting a permission they turn out to need.
|
|
371
|
+
|
|
372
|
+
function narrowings({ manifest, files, used }) {
|
|
373
|
+
const out = [];
|
|
374
|
+
const perms = new Set([...(manifest.permissions || []), ...(manifest.optional_permissions || [])]);
|
|
375
|
+
const hosts = [...(manifest.host_permissions || []), ...(manifest.permissions || []).filter((p) => typeof p === "string" && (p.includes("://") || p === "<all_urls>"))];
|
|
376
|
+
|
|
377
|
+
// tabs -> activeTab. The single most cited narrowing, because chrome.tabs is
|
|
378
|
+
// reached for out of habit while the extension only ever wants the tab the
|
|
379
|
+
// user just acted on.
|
|
380
|
+
if (perms.has("tabs")) {
|
|
381
|
+
const sites = used.tabs || [];
|
|
382
|
+
const needsAll = needsAllTabs(files);
|
|
383
|
+
if (sites.length && !needsAll.length) {
|
|
384
|
+
out.push({
|
|
385
|
+
from: "tabs", to: "activeTab",
|
|
386
|
+
why: "Every chrome.tabs call site here reads the tab the user is already acting on. activeTab grants that on a user gesture and asks for no host permission and no warning at install.",
|
|
387
|
+
evidence: sites.slice(0, 8),
|
|
388
|
+
wrongIf: "you enumerate or observe tabs the user has not interacted with (background sync, tab counters, session restore). Then tabs is correct and this is the justification to give.",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// storage declared while only web storage is used.
|
|
394
|
+
if (perms.has("storage")) {
|
|
395
|
+
const chromeStorage = used.storage || [];
|
|
396
|
+
const webStorage = callSites(files, ["localStorage", "sessionStorage"]);
|
|
397
|
+
if (!chromeStorage.length && webStorage.length) {
|
|
398
|
+
out.push({
|
|
399
|
+
from: "storage", to: "(remove it)",
|
|
400
|
+
why: "The storage permission governs chrome.storage, which never appears here. localStorage and sessionStorage are plain web APIs and need no permission at all.",
|
|
401
|
+
evidence: webStorage.slice(0, 8),
|
|
402
|
+
wrongIf: "chrome.storage is called from a file this pass could not read - check the skipped list before deleting the permission.",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// webRequest -> declarativeNetRequest, the MV3 answer. Only when the
|
|
408
|
+
// permission is actually used: if it is declared and never called, the advice
|
|
409
|
+
// is "delete it", and saying both is the kind of doubled-up output that gets a
|
|
410
|
+
// linter muted.
|
|
411
|
+
if (perms.has("webRequest") && (used.webRequest || []).length) {
|
|
412
|
+
const sites = used.webRequest;
|
|
413
|
+
const blocking = sites.filter((s) => /blocking|onBeforeRequest|onBeforeSendHeaders/.test(s.text));
|
|
414
|
+
out.push({
|
|
415
|
+
from: "webRequest", to: "declarativeNetRequest",
|
|
416
|
+
why: "Under Manifest V3 the blocking form of webRequest is unavailable to most extensions, and declarativeNetRequest covers request blocking and modification without the extension seeing the traffic. Reviewers read a webRequest request as broader than the job needs unless observation is genuinely the feature.",
|
|
417
|
+
evidence: (blocking.length ? blocking : sites).slice(0, 8),
|
|
418
|
+
wrongIf: "you genuinely need to OBSERVE requests rather than change them, or you hold the declarativeNetRequestFeedback use case. Say which, in those words.",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Broad host permissions against the hosts the code actually contacts.
|
|
423
|
+
const broadHosts = hosts.filter((h) => BROAD.has(h));
|
|
424
|
+
if (broadHosts.length) {
|
|
425
|
+
const urls = new Set();
|
|
426
|
+
for (const f of files) {
|
|
427
|
+
if (!isCode(f)) continue;
|
|
428
|
+
for (const m of f.text.matchAll(/https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?![a-z])/gi)) {
|
|
429
|
+
const host = m[1].toLowerCase();
|
|
430
|
+
if (!/^(www\.)?(w3|example|localhost|schemas?)\b/.test(host)) urls.add(host);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const concrete = [...urls].sort();
|
|
434
|
+
out.push({
|
|
435
|
+
from: broadHosts.join(", "), to: concrete.length ? concrete.map((h) => `https://${h}/*`).join(", ") : "(no concrete host found in code)",
|
|
436
|
+
why: concrete.length
|
|
437
|
+
? `The packaged code only ever names ${concrete.length} host(s). A match pattern list is narrower than every site, and it removes the "Read and change all your data on all websites" install warning that costs installs.`
|
|
438
|
+
: "No concrete host appears in the packaged code, so the breadth of this request is not evidenced by anything this pass can see.",
|
|
439
|
+
evidence: concrete.slice(0, 20).map((h) => ({ file: "(derived from code)", line: 0, api: h, text: `https://${h}` })),
|
|
440
|
+
wrongIf: "the extension is a general-purpose tool that genuinely runs on sites the user chooses at runtime (an ad blocker, a reader mode, a password manager). Then every site is the honest answer, and the fix is to justify it rather than narrow it.",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// scripting + broad hosts where activeTab would carry the injection.
|
|
445
|
+
if (perms.has("scripting") && broadHosts.length) {
|
|
446
|
+
const sites = used.scripting || [];
|
|
447
|
+
const onGesture = sites.filter((s) => /executeScript/.test(s.text));
|
|
448
|
+
if (onGesture.length) {
|
|
449
|
+
out.push({
|
|
450
|
+
from: "scripting + " + broadHosts.join(", "), to: "scripting + activeTab",
|
|
451
|
+
why: "Injection that happens because the user clicked can run under activeTab, which grants the current tab for the duration of that gesture and needs no host permission.",
|
|
452
|
+
evidence: onGesture.slice(0, 8),
|
|
453
|
+
wrongIf: "you inject on page load, without a click, on sites the user has not chosen. Then a host permission is required and the narrowing does not apply.",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- the audit ---------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
export function audit({ manifest, files, skipped, oversized = [] }) {
|
|
464
|
+
if (!manifest) return null;
|
|
465
|
+
|
|
466
|
+
const declared = [
|
|
467
|
+
...(manifest.permissions || []).map((p) => ({ name: p, where: "permissions" })),
|
|
468
|
+
...(manifest.optional_permissions || []).map((p) => ({ name: p, where: "optional_permissions" })),
|
|
469
|
+
].filter((p) => typeof p.name === "string" && !p.name.includes("://") && p.name !== "<all_urls>");
|
|
470
|
+
|
|
471
|
+
// Call-site evidence is drawn from code with comments blanked; minification
|
|
472
|
+
// detection below deliberately uses the RAW files, because how the shipped
|
|
473
|
+
// bytes are laid out is the thing it is measuring.
|
|
474
|
+
const code = codeView(files);
|
|
475
|
+
const largeCode = (oversized || []).filter(isCode);
|
|
476
|
+
|
|
477
|
+
const used = {};
|
|
478
|
+
const ledger = [];
|
|
479
|
+
|
|
480
|
+
for (const { name, where } of declared) {
|
|
481
|
+
const apis = PERMISSION_API[name];
|
|
482
|
+
if (!apis) {
|
|
483
|
+
ledger.push({ permission: name, where, status: "unknown", sites: [],
|
|
484
|
+
note: "This pass carries no API pattern for this permission, so it says nothing rather than guessing. Absence here is not evidence of disuse." });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
// The oversized files are searched here too, so the ledger and the
|
|
488
|
+
// unused-permissions rule cannot answer the same question differently -
|
|
489
|
+
// which they would have, the moment the rule started reading them.
|
|
490
|
+
const sites = apis.length ? [...callSites(code, apis), ...largeCallSites(largeCode, apis)] : [];
|
|
491
|
+
used[name] = sites;
|
|
492
|
+
if (NO_NAMESPACE_PERMISSIONS.has(name)) {
|
|
493
|
+
ledger.push({ permission: name, where, status: "used", sites: [],
|
|
494
|
+
note: NO_NAMESPACE_PERMISSIONS.get(name) });
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// No call sites is not the same as no evidence: some permissions are earned
|
|
498
|
+
// by the manifest alone. Consulted only when the code shows nothing, because
|
|
499
|
+
// a call site is the better evidence when it exists.
|
|
500
|
+
if (!sites.length) {
|
|
501
|
+
const evidence = MANIFEST_EVIDENCE[name]?.(manifest) || null;
|
|
502
|
+
if (evidence) {
|
|
503
|
+
ledger.push({
|
|
504
|
+
permission: name, where, status: "used", sites: [], siteCount: 0,
|
|
505
|
+
disclosure: DISCLOSURE_CATEGORY[name] || null,
|
|
506
|
+
evidencedBy: "manifest",
|
|
507
|
+
note: evidence,
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
ledger.push({
|
|
513
|
+
permission: name, where,
|
|
514
|
+
status: sites.length ? "used" : "unused",
|
|
515
|
+
sites: sites.slice(0, 12),
|
|
516
|
+
siteCount: sites.length,
|
|
517
|
+
disclosure: DISCLOSURE_CATEGORY[name] || null,
|
|
518
|
+
note: sites.length
|
|
519
|
+
? null
|
|
520
|
+
: "Declared, and its chrome.* namespace appears in none of the packaged JavaScript this pass could read. This is Google's stated trigger for Purple Potassium.",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const minified = [...looksMinified(files), ...looksMinifiedLarge(largeCode)];
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
manifestVersion: manifest.manifest_version,
|
|
528
|
+
ledger,
|
|
529
|
+
narrowings: narrowings({ manifest, files: code, used }),
|
|
530
|
+
disclosures: ledger
|
|
531
|
+
.filter((l) => l.status === "used" && l.disclosure)
|
|
532
|
+
.map((l) => ({ permission: l.permission, category: l.disclosure, sites: l.sites.slice(0, 3) })),
|
|
533
|
+
confidence: {
|
|
534
|
+
filesRead: files.filter(isCode).length,
|
|
535
|
+
skipped,
|
|
536
|
+
minified,
|
|
537
|
+
// The one sentence that keeps this honest. Every "unused" verdict below is
|
|
538
|
+
// scoped to what was actually read, and this names the gap.
|
|
539
|
+
caveat: minified.length || skipped.length
|
|
540
|
+
? "Call-site evidence is incomplete: see minified and skipped. An 'unused' verdict over a bundle or an unread file is not a finding, and must be checked against the original source before any permission is deleted."
|
|
541
|
+
: "Every packaged JavaScript file was read as source. Call-site evidence is complete over the submitted package.",
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|