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/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
+ }