@extenshi/cli 0.23.1 → 0.25.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 +1 -1
- package/dist/amo-source.d.ts +88 -17
- package/dist/amo-source.d.ts.map +1 -1
- package/dist/amo-source.js +442 -116
- package/dist/amo-source.js.map +1 -1
- package/dist/amowebstore-md.d.ts +26 -0
- package/dist/amowebstore-md.d.ts.map +1 -1
- package/dist/amowebstore-md.js +32 -3
- package/dist/amowebstore-md.js.map +1 -1
- package/dist/auto-update.d.ts +46 -3
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +94 -19
- package/dist/auto-update.js.map +1 -1
- package/dist/chromewebstore-md.d.ts +7 -0
- package/dist/chromewebstore-md.d.ts.map +1 -1
- package/dist/chromewebstore-md.js +20 -3
- package/dist/chromewebstore-md.js.map +1 -1
- package/dist/cli.js +73 -20
- package/dist/cli.js.map +1 -1
- package/dist/compliance-review.d.ts +5 -1
- package/dist/compliance-review.d.ts.map +1 -1
- package/dist/compliance-review.js +10 -5
- package/dist/compliance-review.js.map +1 -1
- package/dist/cws-policies.d.ts.map +1 -1
- package/dist/cws-policies.js +4 -0
- package/dist/cws-policies.js.map +1 -1
- package/dist/edgewebstore-md.d.ts +14 -0
- package/dist/edgewebstore-md.d.ts.map +1 -1
- package/dist/edgewebstore-md.js +20 -5
- package/dist/edgewebstore-md.js.map +1 -1
- package/dist/evidence-ingest.d.ts +67 -5
- package/dist/evidence-ingest.d.ts.map +1 -1
- package/dist/evidence-ingest.js +146 -21
- package/dist/evidence-ingest.js.map +1 -1
- package/dist/generate-listing.d.ts +18 -6
- package/dist/generate-listing.d.ts.map +1 -1
- package/dist/generate-listing.js +62 -19
- package/dist/generate-listing.js.map +1 -1
- package/dist/json-ld.d.ts +43 -5
- package/dist/json-ld.d.ts.map +1 -1
- package/dist/json-ld.js +53 -1
- package/dist/json-ld.js.map +1 -1
- package/dist/landing-sections.d.ts +21 -3
- package/dist/landing-sections.d.ts.map +1 -1
- package/dist/landing-sections.js +63 -17
- package/dist/landing-sections.js.map +1 -1
- package/dist/listing-checks.d.ts +15 -9
- package/dist/listing-checks.d.ts.map +1 -1
- package/dist/listing-checks.js +54 -46
- package/dist/listing-checks.js.map +1 -1
- package/dist/listing-data-flows.d.ts +140 -0
- package/dist/listing-data-flows.d.ts.map +1 -0
- package/dist/listing-data-flows.js +454 -0
- package/dist/listing-data-flows.js.map +1 -0
- package/dist/listing.d.ts +48 -0
- package/dist/listing.d.ts.map +1 -1
- package/dist/listing.js +201 -23
- package/dist/listing.js.map +1 -1
- package/dist/localize-parity.d.ts +26 -0
- package/dist/localize-parity.d.ts.map +1 -0
- package/dist/localize-parity.js +227 -0
- package/dist/localize-parity.js.map +1 -0
- package/dist/localize.d.ts +6 -0
- package/dist/localize.d.ts.map +1 -1
- package/dist/localize.js +4 -4
- package/dist/localize.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +1 -2
- package/dist/media.js.map +1 -1
- package/dist/package.d.ts +13 -0
- package/dist/package.d.ts.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/page.d.ts +16 -0
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +49 -4
- package/dist/page.js.map +1 -1
- package/dist/permission-justifications.d.ts +19 -1
- package/dist/permission-justifications.d.ts.map +1 -1
- package/dist/permission-justifications.js +129 -0
- package/dist/permission-justifications.js.map +1 -1
- package/dist/png.d.ts +40 -0
- package/dist/png.d.ts.map +1 -0
- package/dist/png.js +266 -0
- package/dist/png.js.map +1 -0
- package/dist/review-checks.d.ts +54 -13
- package/dist/review-checks.d.ts.map +1 -1
- package/dist/review-checks.js +367 -56
- package/dist/review-checks.js.map +1 -1
- package/dist/review-risk.d.ts +8 -3
- package/dist/review-risk.d.ts.map +1 -1
- package/dist/review-risk.js +14 -4
- package/dist/review-risk.js.map +1 -1
- package/dist/review-stores.d.ts +63 -0
- package/dist/review-stores.d.ts.map +1 -0
- package/dist/review-stores.js +92 -0
- package/dist/review-stores.js.map +1 -0
- package/dist/screenshots-scenes.d.ts +13 -0
- package/dist/screenshots-scenes.d.ts.map +1 -1
- package/dist/screenshots-scenes.js +20 -2
- package/dist/screenshots-scenes.js.map +1 -1
- package/dist/screenshots.d.ts +6 -4
- package/dist/screenshots.d.ts.map +1 -1
- package/dist/screenshots.js +31 -11
- package/dist/screenshots.js.map +1 -1
- package/dist/smoke.d.ts +54 -6
- package/dist/smoke.d.ts.map +1 -1
- package/dist/smoke.js +302 -26
- package/dist/smoke.js.map +1 -1
- package/dist/store-assets.d.ts +20 -3
- package/dist/store-assets.d.ts.map +1 -1
- package/dist/store-assets.js +17 -7
- package/dist/store-assets.js.map +1 -1
- package/package.json +1 -1
package/dist/review-checks.js
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* Chrome for Developers documentation so the user can verify the rule at the
|
|
15
15
|
* source. (We do not cite third-party books in product output.)
|
|
16
16
|
*
|
|
17
|
+
* The suite predicts ONE store's review per run (`store`, default Chrome Web
|
|
18
|
+
* Store). Findings that describe a store's review process — the in-depth review
|
|
19
|
+
* of broad host access, re-review on listing edits, the Privacy practices form —
|
|
20
|
+
* exist only for the store whose documentation describes them; see
|
|
21
|
+
* review-stores.ts for the wording that differs.
|
|
22
|
+
*
|
|
17
23
|
* This module is PURE (no I/O) so the rules are unit-testable against fixtures.
|
|
18
24
|
* Archive extraction lives in review-archive.ts; the network manifest fetch in
|
|
19
25
|
* review-catalog.ts; rendering in review-render.ts. The store-listing rules
|
|
@@ -21,7 +27,9 @@
|
|
|
21
27
|
* caller gets one findings list regardless of which surface produced it.
|
|
22
28
|
*/
|
|
23
29
|
import { policyForCheck } from './cws-policies.js';
|
|
30
|
+
import { readLocaleMessages } from './listing.js';
|
|
24
31
|
import { runListingChecks } from './listing-checks.js';
|
|
32
|
+
import { storeRules } from './review-stores.js';
|
|
25
33
|
// ── ch.10 permission → Chrome warning dialog ─────────────────────────────────
|
|
26
34
|
//
|
|
27
35
|
// The auto-disable-on-update behaviour (check 3) hinges on ONE thing: does
|
|
@@ -68,6 +76,64 @@ export const PERMISSION_WARNINGS = {
|
|
|
68
76
|
webAuthenticationProxy: 'Read and change all your data on all websites',
|
|
69
77
|
webNavigation: 'Read your browsing history',
|
|
70
78
|
};
|
|
79
|
+
// ── Firefox: permission → update prompt ──────────────────────────────────────
|
|
80
|
+
//
|
|
81
|
+
// Firefox does not disable anything when an update adds a permission. Extension
|
|
82
|
+
// Workshop, "Request the right permissions"
|
|
83
|
+
// (https://extensionworkshop.com/documentation/develop/request-the-right-permissions/):
|
|
84
|
+
// the user is "prompted to approve the permissions before the updated version is
|
|
85
|
+
// installed", and if they cancel, "the previous version remains installed".
|
|
86
|
+
//
|
|
87
|
+
// Host permissions of a Manifest V3 extension are the exception. MDN,
|
|
88
|
+
// manifest.json/host_permissions
|
|
89
|
+
// (https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/host_permissions):
|
|
90
|
+
// since Firefox 127 they are in the install prompt, but new ones requested by an
|
|
91
|
+
// update "are not shown to the user" (bug 1893232, still NEW on 2026-10-11).
|
|
92
|
+
// The bug's comment 2 (Mozilla, 2025-05-29) says what happens instead: "the
|
|
93
|
+
// extension is updated without adding the requested host permission", while an
|
|
94
|
+
// MV2 extension is not updated until the user approves.
|
|
95
|
+
//
|
|
96
|
+
// Which API permissions prompt is Firefox's own list, not Chrome's:
|
|
97
|
+
// PERMISSIONS_WITH_MESSAGE in toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs,
|
|
98
|
+
// with the messages from toolkit/locales/en-US/toolkit/global/extensionPermissions.ftl
|
|
99
|
+
// (mozilla-firefox/firefox, main, 2026-10-11). Neither has identity or
|
|
100
|
+
// unlimitedStorage. Left out: userScripts and trialML, which Firefox's
|
|
101
|
+
// manifest schema declares OptionalOnlyPermission (schemas/user_scripts.json,
|
|
102
|
+
// schemas/trial_ml.json) — in `permissions` they are dropped with a warning,
|
|
103
|
+
// so an update never prompts for them. Every key here can be moved to
|
|
104
|
+
// optional_permissions (MDN, manifest.json/optional_permissions). Keep in sync
|
|
105
|
+
// with shared-types/permission-warnings.ts (`firefoxWarningByPermission`) —
|
|
106
|
+
// pinned by permission-warnings-sync.test.ts.
|
|
107
|
+
//
|
|
108
|
+
// `devtools` is also implied: Extension.sys.mjs getRequiredPermissions() adds
|
|
109
|
+
// it whenever the manifest has a devtools_page and does not list devtools in
|
|
110
|
+
// optional_permissions — see firefoxRequiredApiPermissions().
|
|
111
|
+
export const FIREFOX_PERMISSION_MESSAGES = {
|
|
112
|
+
bookmarks: 'Read and modify bookmarks',
|
|
113
|
+
browserSettings: 'Read and modify browser settings',
|
|
114
|
+
browsingData: 'Clear recent browsing history, cookies, and related data',
|
|
115
|
+
clipboardRead: 'Get data from the clipboard',
|
|
116
|
+
clipboardWrite: 'Input data to the clipboard',
|
|
117
|
+
declarativeNetRequest: 'Block content on any page',
|
|
118
|
+
declarativeNetRequestFeedback: 'Read your browsing history',
|
|
119
|
+
devtools: 'Extend developer tools to access your data in open tabs',
|
|
120
|
+
downloads: 'Download files and read and modify the browser’s download history',
|
|
121
|
+
'downloads.open': 'Open files downloaded to your computer',
|
|
122
|
+
find: 'Read the text of all open tabs',
|
|
123
|
+
geolocation: 'Access your location',
|
|
124
|
+
history: 'Access browsing history',
|
|
125
|
+
management: 'Monitor extension usage and manage themes',
|
|
126
|
+
nativeMessaging: 'Exchange messages with programs other than Firefox',
|
|
127
|
+
notifications: 'Display notifications to you',
|
|
128
|
+
pkcs11: 'Provide cryptographic authentication services',
|
|
129
|
+
privacy: 'Read and modify privacy settings',
|
|
130
|
+
proxy: 'Control browser proxy settings',
|
|
131
|
+
sessions: 'Access recently closed tabs',
|
|
132
|
+
tabs: 'Access browser tabs',
|
|
133
|
+
tabHide: 'Hide and show browser tabs',
|
|
134
|
+
topSites: 'Access browsing history',
|
|
135
|
+
webNavigation: 'Access browser activity during navigation',
|
|
136
|
+
};
|
|
71
137
|
// Official Chrome documentation backing each check (shown as the finding source).
|
|
72
138
|
const DOC_REMOTE_CODE = 'https://developer.chrome.com/docs/webstore/program-policies/code-readability';
|
|
73
139
|
const DOC_REVIEW = 'https://developer.chrome.com/docs/webstore/review-process';
|
|
@@ -75,6 +141,9 @@ const DOC_PERMISSION_WARNINGS = 'https://developer.chrome.com/docs/extensions/de
|
|
|
75
141
|
const DOC_CSP = 'https://developer.chrome.com/docs/extensions/reference/manifest/content-security-policy';
|
|
76
142
|
const DOC_SERVICE_WORKER = 'https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle';
|
|
77
143
|
const DOC_PERMISSIONS = 'https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions';
|
|
144
|
+
const DOC_EDGE_UPDATE = 'https://learn.microsoft.com/en-us/microsoft-edge/extensions/update/update-extension';
|
|
145
|
+
const DOC_FIREFOX_PERMISSIONS = 'https://extensionworkshop.com/documentation/develop/request-the-right-permissions/';
|
|
146
|
+
const DOC_FIREFOX_HOST_PERMISSIONS = 'https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/host_permissions';
|
|
78
147
|
// ── Host-permission helpers ──────────────────────────────────────────────────
|
|
79
148
|
/**
|
|
80
149
|
* The four spellings of "every host" from ch.10 "Global Host Permission" — all
|
|
@@ -140,20 +209,58 @@ export function htmlEntrypoints(m) {
|
|
|
140
209
|
return [...new Set(out)];
|
|
141
210
|
}
|
|
142
211
|
// ── Check 1: slow-review-queue predictor (ch.10) ─────────────────────────────
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Content scripts that inject into every site, as `content_scripts[i].matches: <pattern>`.
|
|
214
|
+
*
|
|
215
|
+
* Chrome grants a content script's match patterns the same host access as
|
|
216
|
+
* `host_permissions` and shows the same warning for them, so an extension that
|
|
217
|
+
* reaches every site only through `content_scripts` has broad host access all
|
|
218
|
+
* the same — and is the common shape for page-styling extensions.
|
|
219
|
+
*/
|
|
220
|
+
function globalContentScriptMatches(m) {
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const [i, cs] of (m.content_scripts ?? []).entries()) {
|
|
223
|
+
for (const pattern of asStringArray(cs?.matches).filter(isGlobalHost)) {
|
|
224
|
+
out.push(`content_scripts[${i}].matches: ${pattern}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Chrome Web Store only. Its review-process page says reviews "may take longer
|
|
231
|
+
* for extensions that request broad host permissions". Edge certifies every
|
|
232
|
+
* submission the same way (up to 7 business days) and AMO publishes after
|
|
233
|
+
* automated checks; neither documents a longer path for broad host access, so
|
|
234
|
+
* this finding would be invented there.
|
|
235
|
+
*/
|
|
236
|
+
export function checkSlowReviewQueue(m, store = 'CHROME') {
|
|
237
|
+
if (store !== 'CHROME')
|
|
146
238
|
return [];
|
|
239
|
+
const global = [...new Set(requiredHostPermissions(m).filter(isGlobalHost))];
|
|
240
|
+
const viaContentScripts = globalContentScriptMatches(m);
|
|
241
|
+
if (global.length === 0 && viaContentScripts.length === 0)
|
|
242
|
+
return [];
|
|
243
|
+
const onlyContentScripts = global.length === 0;
|
|
147
244
|
return [
|
|
148
245
|
{
|
|
149
246
|
verdict: 'SLOW',
|
|
150
247
|
check: 'global-host-permissions',
|
|
151
|
-
title:
|
|
248
|
+
title: onlyContentScripts
|
|
249
|
+
? 'Content scripts on every site put the extension in the slow manual review queue'
|
|
250
|
+
: 'Global host permissions put the extension in the slow manual review queue',
|
|
152
251
|
detail: 'Requesting access to every host (any of <all_urls>, *://*/*, http://*/*, https://*/*) ' +
|
|
153
|
-
'forces a manual review, which typically raises review time from under 24 hours to several days.'
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
252
|
+
'forces a manual review, which typically raises review time from under 24 hours to several days.' +
|
|
253
|
+
(viaContentScripts.length > 0
|
|
254
|
+
? ' Content scripts count: their match patterns grant the same access as host permissions, and ' +
|
|
255
|
+
'Chrome shows the same "Read and change all your data on all websites" warning for them.'
|
|
256
|
+
: ''),
|
|
257
|
+
evidence: [...global, ...viaContentScripts].join(', '),
|
|
258
|
+
recommendation: onlyContentScripts
|
|
259
|
+
? 'Narrow content_scripts.matches to the sites the extension is for, or inject on demand with ' +
|
|
260
|
+
'activeTab + scripting.executeScript() after a click. If it genuinely works on every page, keep ' +
|
|
261
|
+
'it and plan for the longer review.'
|
|
262
|
+
: 'Narrow to the specific hosts you actually call (e.g. https://api.example.com/*), or drop host ' +
|
|
263
|
+
'permissions entirely in favour of activeTab — see the activeTab recommendation below.',
|
|
157
264
|
citation: `Chrome Web Store: review process — ${DOC_REVIEW}`,
|
|
158
265
|
},
|
|
159
266
|
];
|
|
@@ -162,8 +269,29 @@ export function checkSlowReviewQueue(m) {
|
|
|
162
269
|
/**
|
|
163
270
|
* Static advisory — there is nothing in the package to inspect; it's a
|
|
164
271
|
* publishing-process fact developers routinely get burned by. Emitted once.
|
|
272
|
+
*
|
|
273
|
+
* Per store, because the process differs: Edge restarts certification for a
|
|
274
|
+
* metadata-only update too, and AMO documents nothing about listing-only edits
|
|
275
|
+
* (so nothing is said there).
|
|
165
276
|
*/
|
|
166
|
-
export function checkListingChangeReReview() {
|
|
277
|
+
export function checkListingChangeReReview(store = 'CHROME') {
|
|
278
|
+
if (store === 'FIREFOX')
|
|
279
|
+
return [];
|
|
280
|
+
if (store === 'EDGE') {
|
|
281
|
+
return [
|
|
282
|
+
{
|
|
283
|
+
verdict: 'SLOW',
|
|
284
|
+
check: 'listing-change-rereview',
|
|
285
|
+
title: 'Editing the Edge Add-ons listing sends the extension through certification again',
|
|
286
|
+
detail: 'In Partner Center an update to the store listing alone (description, screenshots, search terms) ' +
|
|
287
|
+
'starts the same certification as a new package, which can take up to 7 business days — and ' +
|
|
288
|
+
'editing a submission that is already in review stops that review and starts a new one.',
|
|
289
|
+
recommendation: 'Batch listing edits with a code release, and finish editing before you click Publish rather than ' +
|
|
290
|
+
'while the submission is in review.',
|
|
291
|
+
citation: `Microsoft Edge Add-ons: update an extension — ${DOC_EDGE_UPDATE}`,
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
}
|
|
167
295
|
return [
|
|
168
296
|
{
|
|
169
297
|
verdict: 'SLOW',
|
|
@@ -177,63 +305,144 @@ export function checkListingChangeReReview() {
|
|
|
177
305
|
},
|
|
178
306
|
];
|
|
179
307
|
}
|
|
180
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Chromium (Chrome, Edge): a new required permission that warns disables the
|
|
310
|
+
* extension until the user accepts it. Microsoft publishes no page of its own on
|
|
311
|
+
* this, so Edge findings cite Chrome's and name Microsoft Edge in the text.
|
|
312
|
+
*/
|
|
313
|
+
function chromiumNewPermission(perm, warning, store) {
|
|
314
|
+
const edge = store === 'EDGE';
|
|
315
|
+
return {
|
|
316
|
+
title: `Adding required "${perm}" silently disables the extension${edge ? ' in Microsoft Edge' : ''} for existing users`,
|
|
317
|
+
detail: `"${perm}" shows the warning "${warning}". When an update adds a required permission that warns, ` +
|
|
318
|
+
`${edge ? 'Microsoft Edge' : 'the browser'} silently disables the extension for everyone who already has it ` +
|
|
319
|
+
'installed and shows only a small toolbar icon — most users never re-enable, so you lose them.',
|
|
320
|
+
recommendation: `Move "${perm}" into optional_permissions and request it at runtime with chrome.permissions.request(), ` +
|
|
321
|
+
'so existing users keep the extension enabled.',
|
|
322
|
+
citation: chromiumUpdateCitation(edge),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function chromiumUpdateCitation(edge) {
|
|
326
|
+
return edge
|
|
327
|
+
? `Chrome for Developers (Microsoft Edge runs the same Chromium behaviour): permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`
|
|
328
|
+
: `Chrome: permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`;
|
|
329
|
+
}
|
|
330
|
+
function chromiumNewHost(host, store) {
|
|
331
|
+
const edge = store === 'EDGE';
|
|
332
|
+
const global = isGlobalHost(host);
|
|
333
|
+
return {
|
|
334
|
+
title: `Adding host access "${host}" silently disables the extension${edge ? ' in Microsoft Edge' : ''} for existing users`,
|
|
335
|
+
detail: `Host permissions show a "Read and change ${global ? 'all your data on all websites' : `your data on ${host}`}" ` +
|
|
336
|
+
`warning. Adding one in an update silently disables the extension${edge ? ' in Microsoft Edge' : ''} for ` +
|
|
337
|
+
'the installed base until each user re-accepts — only a small toolbar icon signals it.',
|
|
338
|
+
recommendation: 'Move it to optional_host_permissions and request it on a user gesture, or use activeTab if you only ' +
|
|
339
|
+
'need the active tab.',
|
|
340
|
+
citation: chromiumUpdateCitation(edge),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** Firefox: the update waits for the user's approval; nothing is disabled. */
|
|
344
|
+
function firefoxNewPermission(perm, message) {
|
|
345
|
+
return {
|
|
346
|
+
title: `Adding required "${perm}" holds the update for existing Firefox users until they approve it`,
|
|
347
|
+
detail: `Firefox shows "${perm}" as "${message}". When an update adds a permission with a message, Firefox asks ` +
|
|
348
|
+
'each existing user to approve it before it installs the update. Until they do — and for good, if they ' +
|
|
349
|
+
'decline — they stay on your previous version.',
|
|
350
|
+
recommendation: `Move "${perm}" into optional_permissions and request it with browser.permissions.request() from a user ` +
|
|
351
|
+
'action (a toolbar button or menu item click), so the update installs without a prompt.',
|
|
352
|
+
citation: `Firefox Extension Workshop: Request the right permissions — ${DOC_FIREFOX_PERMISSIONS}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Firefox host access. MV2 hosts sit in `permissions` and hold the update like
|
|
357
|
+
* any other permission; MV3 `host_permissions` added by an update are neither
|
|
358
|
+
* shown nor granted, so the update installs and the new access is just missing.
|
|
359
|
+
*/
|
|
360
|
+
function firefoxNewHost(host, mv3) {
|
|
361
|
+
const target = isGlobalHost(host) ? 'every website' : host;
|
|
362
|
+
if (!mv3) {
|
|
363
|
+
return {
|
|
364
|
+
title: `Adding host access "${host}" holds the update for existing Firefox users until they approve it`,
|
|
365
|
+
detail: `Firefox asks each existing user to approve access to ${target} before it installs the update. ` +
|
|
366
|
+
'Until they do — and for good, if they decline — they stay on your previous version.',
|
|
367
|
+
recommendation: 'Move it to optional_permissions (where Manifest V2 lists optional hosts) and request it with ' +
|
|
368
|
+
'browser.permissions.request() from a user action, or use activeTab if you only need the active tab.',
|
|
369
|
+
citation: `Firefox Extension Workshop: Request the right permissions — ${DOC_FIREFOX_PERMISSIONS}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
title: `Adding host access "${host}" leaves existing Firefox users without it after the update`,
|
|
374
|
+
detail: 'Firefox installs the update but neither shows nor grants host permissions an update adds to a Manifest V3 ' +
|
|
375
|
+
`extension (Firefox bug 1893232). Whatever needs access to ${target} stops working for existing users ` +
|
|
376
|
+
'until each of them grants it in the Firefox Add-ons Manager; only new installs are asked for it.',
|
|
377
|
+
recommendation: 'Move it to optional_host_permissions, check it with browser.permissions.contains(), and request it with ' +
|
|
378
|
+
'browser.permissions.request() from a user action so existing users are asked — or use activeTab if you ' +
|
|
379
|
+
'only need the active tab.',
|
|
380
|
+
citation: `MDN: host_permissions — host permissions added by an update are not shown to the user — ${DOC_FIREFOX_HOST_PERMISSIONS}`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/** Firefox's required API permissions: the declared ones plus `devtools`, implied by a devtools_page. */
|
|
384
|
+
function firefoxRequiredApiPermissions(m) {
|
|
385
|
+
const out = requiredApiPermissions(m);
|
|
386
|
+
if (m.devtools_page &&
|
|
387
|
+
!out.includes('devtools') &&
|
|
388
|
+
!asStringArray(m.optional_permissions).includes('devtools')) {
|
|
389
|
+
out.push('devtools');
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
181
393
|
/**
|
|
182
394
|
* Diff the local manifest against the last-published one. A newly-added
|
|
183
|
-
* *required* permission that
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
395
|
+
* *required* permission that prompts costs the installed base on update →
|
|
396
|
+
* attrition. How depends on the browser: Chromium (Chrome, Edge) silently
|
|
397
|
+
* disables the extension until the user re-accepts (only a small toolbar icon
|
|
398
|
+
* appears); Firefox keeps users on the previous version until they approve, and
|
|
399
|
+
* installs an MV3 update without the host access it adds (see
|
|
400
|
+
* FIREFOX_PERMISSION_MESSAGES). On the Chrome Web Store, newly-added permissions
|
|
401
|
+
* of any kind also re-trip the slow review queue (ch.10).
|
|
187
402
|
*
|
|
188
403
|
* `prior` is the manifest fetched from the catalog's last snapshot; pass null
|
|
189
404
|
* when it could not be fetched (the caller surfaces why and this returns []).
|
|
190
405
|
*/
|
|
191
|
-
export function checkAutoDisableOnUpdate(local, prior) {
|
|
406
|
+
export function checkAutoDisableOnUpdate(local, prior, store = 'CHROME') {
|
|
192
407
|
if (!prior)
|
|
193
408
|
return [];
|
|
194
409
|
const findings = [];
|
|
195
|
-
const
|
|
410
|
+
const firefox = store === 'FIREFOX';
|
|
411
|
+
const apiPermissions = firefox ? firefoxRequiredApiPermissions : requiredApiPermissions;
|
|
412
|
+
const priorApi = new Set(apiPermissions(prior));
|
|
196
413
|
const priorHosts = new Set(requiredHostPermissions(prior));
|
|
197
|
-
const newApi =
|
|
414
|
+
const newApi = apiPermissions(local).filter((p) => !priorApi.has(p));
|
|
198
415
|
const newHosts = requiredHostPermissions(local).filter((h) => !priorHosts.has(h));
|
|
199
|
-
|
|
416
|
+
const declaredApi = new Set(requiredApiPermissions(local));
|
|
417
|
+
// Newly-added required API permissions that prompt → attrition. Each browser
|
|
418
|
+
// has its own list of which ones prompt.
|
|
200
419
|
for (const perm of newApi) {
|
|
201
|
-
const warning = PERMISSION_WARNINGS[perm];
|
|
420
|
+
const warning = firefox ? FIREFOX_PERMISSION_MESSAGES[perm] : PERMISSION_WARNINGS[perm];
|
|
202
421
|
if (!warning)
|
|
203
422
|
continue; // silently-granted permission — safe to add in an update
|
|
204
423
|
findings.push({
|
|
205
424
|
verdict: 'ATTRITION',
|
|
206
425
|
check: 'auto-disable-new-permission',
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
evidence: `new required permission "${perm}" (absent from the last published manifest)`,
|
|
212
|
-
recommendation: `Move "${perm}" into optional_permissions and request it at runtime with chrome.permissions.request(), ` +
|
|
213
|
-
'so existing users keep the extension enabled.',
|
|
214
|
-
citation: `Chrome: permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`,
|
|
426
|
+
...(firefox ? firefoxNewPermission(perm, warning) : chromiumNewPermission(perm, warning, store)),
|
|
427
|
+
evidence: declaredApi.has(perm)
|
|
428
|
+
? `new required permission "${perm}" (absent from the last published manifest)`
|
|
429
|
+
: `new devtools_page — Firefox grants "${perm}" with it (absent from the last published manifest)`,
|
|
215
430
|
});
|
|
216
431
|
}
|
|
217
432
|
// Newly-added host permissions always warn ("Read and change your data on …")
|
|
218
|
-
// → same
|
|
433
|
+
// → same attrition, except that Firefox grants none of them to an MV3 update.
|
|
219
434
|
for (const host of newHosts) {
|
|
220
|
-
const global = isGlobalHost(host);
|
|
221
435
|
findings.push({
|
|
222
436
|
verdict: 'ATTRITION',
|
|
223
437
|
check: 'auto-disable-new-host',
|
|
224
|
-
|
|
225
|
-
detail: `Host permissions show a "Read and change ${global ? 'all your data on all websites' : `your data on ${host}`}" ` +
|
|
226
|
-
'warning. Adding one in an update silently disables the extension for the installed base until each user ' +
|
|
227
|
-
're-accepts — only a small toolbar icon signals it.',
|
|
438
|
+
...(firefox ? firefoxNewHost(host, local.manifest_version === 3) : chromiumNewHost(host, store)),
|
|
228
439
|
evidence: `new host permission "${host}" (absent from the last published manifest)`,
|
|
229
|
-
recommendation: 'Move it to optional_host_permissions and request it on a user gesture, or use activeTab if you only ' +
|
|
230
|
-
'need the active tab.',
|
|
231
|
-
citation: `Chrome: permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`,
|
|
232
440
|
});
|
|
233
441
|
}
|
|
234
442
|
// Any new permission also re-trips the slow review queue (ch.10), independent
|
|
235
|
-
// of whether it warns. One consolidated note.
|
|
236
|
-
|
|
443
|
+
// of whether it warns. One consolidated note. A Chrome Web Store process
|
|
444
|
+
// fact: Edge certifies every update the same way, AMO documents no such queue.
|
|
445
|
+
if (store === 'CHROME' && newApi.length + newHosts.length > 0) {
|
|
237
446
|
findings.push({
|
|
238
447
|
verdict: 'SLOW',
|
|
239
448
|
check: 'update-adds-permissions',
|
|
@@ -533,13 +742,16 @@ export function checkServiceWorker(m, files) {
|
|
|
533
742
|
// ── Check 5: activeTab advisor (ch.10) ───────────────────────────────────────
|
|
534
743
|
/** True when a content script auto-injects into all pages (then activeTab can't replace its host perms). */
|
|
535
744
|
function hasBroadContentScript(m) {
|
|
536
|
-
|
|
537
|
-
if (asStringArray(cs.matches).some(isGlobalHost))
|
|
538
|
-
return true;
|
|
539
|
-
}
|
|
540
|
-
return false;
|
|
745
|
+
return globalContentScriptMatches(m).length > 0;
|
|
541
746
|
}
|
|
542
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Chrome Web Store only: the advice holds everywhere, but the verdict it is
|
|
749
|
+
* filed under — skipping the slow review queue — is a Chrome Web Store process
|
|
750
|
+
* fact (see `checkSlowReviewQueue`).
|
|
751
|
+
*/
|
|
752
|
+
export function checkActiveTabAdvisor(m, store = 'CHROME') {
|
|
753
|
+
if (store !== 'CHROME')
|
|
754
|
+
return [];
|
|
543
755
|
const global = requiredHostPermissions(m).filter(isGlobalHost);
|
|
544
756
|
if (global.length === 0)
|
|
545
757
|
return [];
|
|
@@ -785,7 +997,10 @@ const PERMISSION_DISCLOSURES = [
|
|
|
785
997
|
* Optional permissions count: the declaration covers what the extension can
|
|
786
998
|
* request, and an optional permission ships in the package and is grantable.
|
|
787
999
|
*/
|
|
788
|
-
export function checkPrivacyDisclosure(manifest) {
|
|
1000
|
+
export function checkPrivacyDisclosure(manifest, store = 'CHROME') {
|
|
1001
|
+
// The questions it predicts are the Chrome Web Store dashboard's form.
|
|
1002
|
+
if (store !== 'CHROME')
|
|
1003
|
+
return [];
|
|
789
1004
|
const held = new Set();
|
|
790
1005
|
for (const key of ['permissions', 'optional_permissions']) {
|
|
791
1006
|
const raw = manifest[key];
|
|
@@ -855,6 +1070,92 @@ export function privacyPracticesForManifest(manifest) {
|
|
|
855
1070
|
}
|
|
856
1071
|
return out;
|
|
857
1072
|
}
|
|
1073
|
+
// ── Check: manifest field lengths, per locale ────────────────────────────────
|
|
1074
|
+
//
|
|
1075
|
+
// The store shows `name`, `short_name` and `description` as each locale
|
|
1076
|
+
// resolves them, and documents a maximum for each — so the limit binds every
|
|
1077
|
+
// `_locales/<lang>/messages.json`, not only the default one. Translations run
|
|
1078
|
+
// longer than English (German, Russian), which is exactly where a field that
|
|
1079
|
+
// fits in `en` stops fitting.
|
|
1080
|
+
//
|
|
1081
|
+
// Advisory (SLOW) like the listing rules: the stores document the numbers but
|
|
1082
|
+
// not what they do with a longer value, and a false REJECTED fails a release.
|
|
1083
|
+
const LENGTH_FIELDS = ['name', 'short_name', 'description'];
|
|
1084
|
+
const MSG_KEY_RE = /^__MSG_([A-Za-z0-9_@]+)__$/;
|
|
1085
|
+
/**
|
|
1086
|
+
* The text a store shows for one message: `$name$` placeholders replaced by
|
|
1087
|
+
* their `content`, `$$` by `$`. Message names are case-insensitive in the
|
|
1088
|
+
* WebExtensions i18n format, both the key and the placeholder name.
|
|
1089
|
+
*/
|
|
1090
|
+
export function resolveMessage(messages, key) {
|
|
1091
|
+
const found = Object.entries(messages).find(([k]) => k.toLowerCase() === key.toLowerCase())?.[1];
|
|
1092
|
+
if (!found || typeof found !== 'object')
|
|
1093
|
+
return undefined;
|
|
1094
|
+
const { message, placeholders } = found;
|
|
1095
|
+
if (typeof message !== 'string' || !message.trim())
|
|
1096
|
+
return undefined;
|
|
1097
|
+
const named = placeholders && typeof placeholders === 'object' ? placeholders : {};
|
|
1098
|
+
return message.replace(/\$\$|\$([A-Za-z0-9_@]+)\$/g, (token, name) => {
|
|
1099
|
+
if (token === '$$')
|
|
1100
|
+
return '$';
|
|
1101
|
+
const entry = Object.entries(named).find(([k]) => k.toLowerCase() === name?.toLowerCase())?.[1];
|
|
1102
|
+
const content = entry && typeof entry === 'object' ? entry.content : undefined;
|
|
1103
|
+
return typeof content === 'string' ? content : token;
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
export function checkManifestFieldLengths(manifest, files, store = 'CHROME') {
|
|
1107
|
+
const rules = storeRules(store);
|
|
1108
|
+
const messagesByLocale = readLocaleMessages(files);
|
|
1109
|
+
const out = [];
|
|
1110
|
+
for (const field of LENGTH_FIELDS) {
|
|
1111
|
+
const limit = rules.fieldLimits[field];
|
|
1112
|
+
const raw = manifest[field];
|
|
1113
|
+
if (!limit || typeof raw !== 'string' || !raw.trim())
|
|
1114
|
+
continue;
|
|
1115
|
+
const key = MSG_KEY_RE.exec(raw.trim())?.[1];
|
|
1116
|
+
const values = [];
|
|
1117
|
+
if (!key)
|
|
1118
|
+
values.push({ text: raw });
|
|
1119
|
+
// Code-point order, as Python's sorted() in the replay mirror: the per-check
|
|
1120
|
+
// cap keeps the first ten, so both sides must agree which ten.
|
|
1121
|
+
else
|
|
1122
|
+
for (const [locale, messages] of [...messagesByLocale].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
1123
|
+
const text = resolveMessage(messages, key);
|
|
1124
|
+
if (text)
|
|
1125
|
+
values.push({ locale, text });
|
|
1126
|
+
}
|
|
1127
|
+
for (const { locale, text } of values) {
|
|
1128
|
+
// Code points, as the store counters show them — `.length` would count
|
|
1129
|
+
// an emoji or a CJK extension character twice.
|
|
1130
|
+
const length = [...text].length;
|
|
1131
|
+
if (length <= limit.max)
|
|
1132
|
+
continue;
|
|
1133
|
+
const place = locale ? `_locales/${locale}/messages.json` : 'manifest.json';
|
|
1134
|
+
const recommended = field === 'short_name';
|
|
1135
|
+
out.push({
|
|
1136
|
+
verdict: 'SLOW',
|
|
1137
|
+
check: 'manifest-field-length',
|
|
1138
|
+
title: `${field} is ${length} characters${locale ? ` in [${locale}]` : ''} — ${rules.label} ${recommended ? 'recommends at most' : 'allows'} ${limit.max}`,
|
|
1139
|
+
detail: recommended
|
|
1140
|
+
? `${rules.label} recommends at most ${limit.max} characters for short_name and truncates a longer one ` +
|
|
1141
|
+
'where space is limited. The limit applies to every locale as that locale resolves it.'
|
|
1142
|
+
: `${rules.label} allows at most ${limit.max} characters in the manifest ${field}` +
|
|
1143
|
+
(store === 'FIREFOX' && field === 'description'
|
|
1144
|
+
? ', which AMO shows as the add-on summary'
|
|
1145
|
+
: '') +
|
|
1146
|
+
', and the limit applies to every locale as that locale resolves it. What happens to a longer ' +
|
|
1147
|
+
'value is not documented — it may be refused at upload or cut off where it is shown — so shorten ' +
|
|
1148
|
+
'it before submitting.',
|
|
1149
|
+
evidence: `[${locale ?? 'manifest'}] ${field}: ${length} characters — "${[...text].slice(0, 60).join('')}${length > 60 ? '…' : ''}"`,
|
|
1150
|
+
recommendation: key
|
|
1151
|
+
? `Shorten "${key}" in ${place} to ${limit.max} characters or fewer.`
|
|
1152
|
+
: `Shorten ${field} in manifest.json to ${limit.max} characters or fewer.`,
|
|
1153
|
+
citation: `${rules.label}: ${field} length limit — ${limit.source}`,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return out;
|
|
1158
|
+
}
|
|
858
1159
|
/**
|
|
859
1160
|
* Drop exact duplicates (same check + same evidence — a single bundled pattern
|
|
860
1161
|
* copied across N entry chunks would otherwise become N "REJECTED" rows) and cap
|
|
@@ -893,18 +1194,20 @@ function dedupeAndCap(findings) {
|
|
|
893
1194
|
return out;
|
|
894
1195
|
}
|
|
895
1196
|
export function runChecks(input) {
|
|
1197
|
+
const store = input.store ?? 'CHROME';
|
|
896
1198
|
return withPolicies(dedupeAndCap([
|
|
897
1199
|
...checkRemoteCode(input.files),
|
|
898
1200
|
...checkHtmlCsp(input.manifest, input.files),
|
|
899
1201
|
...checkServiceWorker(input.manifest, input.files),
|
|
900
|
-
...checkAutoDisableOnUpdate(input.manifest, input.priorManifest),
|
|
901
|
-
...checkSlowReviewQueue(input.manifest),
|
|
902
|
-
...checkActiveTabAdvisor(input.manifest),
|
|
1202
|
+
...checkAutoDisableOnUpdate(input.manifest, input.priorManifest, store),
|
|
1203
|
+
...checkSlowReviewQueue(input.manifest, store),
|
|
1204
|
+
...checkActiveTabAdvisor(input.manifest, store),
|
|
903
1205
|
...checkUnusedPermissions(input.manifest, input.files),
|
|
904
|
-
...checkPrivacyDisclosure(input.manifest),
|
|
905
|
-
...
|
|
906
|
-
...
|
|
907
|
-
|
|
1206
|
+
...checkPrivacyDisclosure(input.manifest, store),
|
|
1207
|
+
...checkManifestFieldLengths(input.manifest, input.files, store),
|
|
1208
|
+
...runListingChecks(input.listing ?? null, storeRules(store)),
|
|
1209
|
+
...checkListingChangeReReview(store),
|
|
1210
|
+
]), store);
|
|
908
1211
|
}
|
|
909
1212
|
/**
|
|
910
1213
|
* The store-listing rules alone — deduped, capped and policy-stamped exactly as
|
|
@@ -928,8 +1231,8 @@ export function runChecks(input) {
|
|
|
928
1231
|
* ever reuses an id across the two sets breaks that, and would make `scan` and
|
|
929
1232
|
* `review-risk` disagree on counts.
|
|
930
1233
|
*/
|
|
931
|
-
export function runListingOnlyChecks(listing) {
|
|
932
|
-
return withPolicies(dedupeAndCap(runListingChecks(listing)));
|
|
1234
|
+
export function runListingOnlyChecks(listing, store = 'CHROME') {
|
|
1235
|
+
return withPolicies(dedupeAndCap(runListingChecks(listing, storeRules(store))), store);
|
|
933
1236
|
}
|
|
934
1237
|
/**
|
|
935
1238
|
* Stamp each finding with the store policy its check is registered against.
|
|
@@ -939,8 +1242,13 @@ export function runListingOnlyChecks(listing) {
|
|
|
939
1242
|
* pass because it only sees the registry. An unregistered check id is left
|
|
940
1243
|
* without a policy rather than guessed at — the test catches it, and until then
|
|
941
1244
|
* the report says nothing instead of saying something wrong.
|
|
1245
|
+
*
|
|
1246
|
+
* The registry is the Chrome Web Store's, so findings for another store carry
|
|
1247
|
+
* no `policy`: their `citation` names that store's own rule.
|
|
942
1248
|
*/
|
|
943
|
-
function withPolicies(findings) {
|
|
1249
|
+
function withPolicies(findings, store = 'CHROME') {
|
|
1250
|
+
if (store !== 'CHROME')
|
|
1251
|
+
return findings;
|
|
944
1252
|
return findings.map((f) => {
|
|
945
1253
|
const policy = policyForCheck(f.check);
|
|
946
1254
|
return policy === undefined ? f : { ...f, policy };
|
|
@@ -951,8 +1259,11 @@ const VERDICT_ORDER = { REJECTED: 0, ATTRITION: 1, SLOW: 2 };
|
|
|
951
1259
|
* Ruleset version stamped on a persisted `review_risk` prediction.
|
|
952
1260
|
* Bump when a check is added, removed, or its verdict changes — otherwise
|
|
953
1261
|
* iteration 4 averages incompatible rules into one precision number.
|
|
1262
|
+
*
|
|
1263
|
+
* '2' (2026-10-10): `global-host-permissions` also reads content_scripts
|
|
1264
|
+
* matches, and `manifest-field-length` is new.
|
|
954
1265
|
*/
|
|
955
|
-
export const REVIEW_RISK_RULES_VERSION = '
|
|
1266
|
+
export const REVIEW_RISK_RULES_VERSION = '2';
|
|
956
1267
|
export function summarize(findings) {
|
|
957
1268
|
const sorted = [...findings].sort((a, b) => VERDICT_ORDER[a.verdict] - VERDICT_ORDER[b.verdict]);
|
|
958
1269
|
const counts = { REJECTED: 0, ATTRITION: 0, SLOW: 0 };
|