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