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/src/rules.mjs ADDED
@@ -0,0 +1,1549 @@
1
+ // The rules.
2
+ //
3
+ // EVERY RULE CITES A CATEGORY IN data/policy.json, and that file is generated
4
+ // from a dataset whose quotes were re-checked against Google's raw HTML. A rule
5
+ // may describe what it found; it may not paraphrase what Google requires.
6
+ //
7
+ // THREE SEVERITIES, and the boundary between them is the honest part:
8
+ //
9
+ // fail - a static signal Google names as a trigger. Not a prediction of
10
+ // rejection; reviewers are human and this tool cannot install your
11
+ // extension. It means the package contains the thing the policy names.
12
+ // warn - the package shows a condition that REQUIRES something this tool
13
+ // cannot see (a store listing, a privacy policy page, a screenshot).
14
+ // Unresolvable locally by construction, so it is never a fail.
15
+ // info - announced but not yet in any live policy page, or advisory.
16
+ //
17
+ // A linter that reports everything as an error gets muted, and a muted linter
18
+ // is worth less than none. When in doubt a rule warns and says what a human
19
+ // must check.
20
+
21
+ import { grep, grepAcross, grepLarge, largeWindows, isCode, isMarkup, codeView, excerptAround } from "./scan.mjs";
22
+ import {
23
+ MANIFEST_EVIDENCE, PERMISSION_API, NO_NAMESPACE_PERMISSIONS,
24
+ namespaceUsed, looksMinified, looksMinifiedLarge, bareImports,
25
+ } from "./audit.mjs";
26
+
27
+ // Permissions whose presence means USER data is in play. Used by the disclosure
28
+ // and privacy-policy rules. Kept explicit rather than inferred: a list you can
29
+ // read and argue with beats a heuristic nobody can audit.
30
+ //
31
+ // "storage" IS DELIBERATELY ABSENT and the omission is the important part.
32
+ // chrome.storage holds the extension's own state, not the user's data, and
33
+ // almost every extension declares it - so including it made the disclosure rule
34
+ // fire on a fixture that is compliant by construction. A warning that appears on
35
+ // every extension carries no information and teaches people to skip the output.
36
+ // The clean-fixture test is what caught it. Same reasoning excludes alarms,
37
+ // notifications, contextMenus and scripting.
38
+ export const DATA_PERMISSIONS = [
39
+ "cookies", "history", "topSites", "browsingData", "bookmarks", "downloads",
40
+ "geolocation", "clipboardRead", "tabs", "webRequest", "webNavigation",
41
+ "identity", "identity.email", "management", "privacy",
42
+ "declarativeNetRequestFeedback", "pageCapture", "desktopCapture", "audioCapture",
43
+ "videoCapture", "contentSettings", "debugger", "proxy",
44
+ ];
45
+
46
+ // The chrome.* namespaces a permission unlocks live in audit.mjs and are imported
47
+ // above. A permission whose namespace never appears is the exact trigger Google
48
+ // names for Purple Potassium. There used to be a second copy of that table here,
49
+ // on the reasoning that this rule wants a boolean where the audit wants call
50
+ // sites - true of the consumers, not of the data, and the two drifted by four
51
+ // permissions before anyone counted them (T-0421).
52
+
53
+ const BROAD_HOSTS = ["<all_urls>", "*://*/*", "http://*/*", "https://*/*"];
54
+
55
+ // Words that carry no ranking value, excluded from the keyword-stuffing count.
56
+ //
57
+ // WITHOUT THIS THE RULE FIRED ON ORDINARY ENGLISH. The word pattern demands three
58
+ // letters or more, which "the", "and", "for" and "you" all clear, so a plain
59
+ // two-sentence description saying "the" five times was reported as repeating
60
+ // terms. Google's trigger is padding a listing with search terms; "the" is not a
61
+ // search term anybody stuffs. This file's own header says a warning that appears
62
+ // on every extension carries no information, and that is what this had become.
63
+ // The bad fixture stuffs "coupons" and "deals", which are exactly the words this
64
+ // list does not contain, so the rule still catches what it is for.
65
+ const STOPWORDS = new Set([
66
+ "the", "and", "for", "you", "your", "with", "that", "this", "from", "are",
67
+ "can", "not", "but", "all", "any", "was", "were", "has", "have", "had",
68
+ "will", "when", "what", "which", "who", "how", "why", "its", "our", "their",
69
+ "them", "they", "than", "then", "into", "onto", "over", "out", "off", "one",
70
+ "get", "gets", "use", "uses", "using", "just", "also", "more", "most", "only",
71
+ ]);
72
+
73
+ // Every place a manifest can ask for access to a page, in one function because
74
+ // two rules used to collect them separately and DRIFTED. broad-host-permissions
75
+ // read all three keys; disclosure-2026 read only host_permissions, so two
76
+ // extensions with identical access to every site got different answers about the
77
+ // 1 August disclosure rule depending on which key they declared it in - and the
78
+ // content_scripts shape, which is the common one, was the one told nothing.
79
+ //
80
+ // The three keys are not interchangeable spellings. content_scripts[].matches is
81
+ // where an MV3 extension that only reads pages declares itself and it needs no
82
+ // host_permissions at all; hosts listed in `permissions` is the MV2 spelling,
83
+ // and MV2 extensions are the ones already in trouble.
84
+ //
85
+ // Callers filter to BROAD_HOSTS. Narrow matches are deliberately NOT treated as
86
+ // user data in scope: the clean fixture is a reading-time extension with a
87
+ // content script on one site, and a warning that fires on that fires on almost
88
+ // everything. Same reasoning as the "storage" omission from DATA_PERMISSIONS.
89
+ export function declaredHosts(manifest) {
90
+ if (!manifest) return [];
91
+ return [
92
+ ...(manifest.host_permissions || []),
93
+ ...(manifest.permissions || []).filter((p) => typeof p === "string" && (p.includes("://") || p === "<all_urls>")),
94
+ ...(manifest.content_scripts || []).flatMap((c) => c.matches || []),
95
+ ];
96
+ }
97
+
98
+ const finding = (o) => ({ evidence: [], ...o });
99
+
100
+ // Replace every match with the same number of characters, keeping newlines
101
+ // where they are. Offsets and line numbers survive, so a hit found AFTER
102
+ // blanking still points at the right line - which is the whole reason this
103
+ // blanks rather than deletes. Same idea as stripComments() in scan.mjs: a
104
+ // construct that must not be searched is erased in place, not cut out.
105
+ function blank(text, re) {
106
+ return text.replace(re, (m) => m.replace(/[^\n]/g, " "));
107
+ }
108
+
109
+ // ONE implementation of "is this directory a source tree", shared by the two
110
+ // rules that ask. It is memoised on the files array rather than plumbed through
111
+ // the context on purpose: a seam that a caller has to remember to fill is a
112
+ // seam that silently reads empty the first time somebody forgets (T-0291), and
113
+ // the failure would be this rule quietly never firing.
114
+ const bareCache = new WeakMap();
115
+ function bareOf(files) {
116
+ if (bareCache.has(files)) return bareCache.get(files);
117
+ const v = bareImports(codeView(files).filter(isCode));
118
+ bareCache.set(files, v);
119
+ return v;
120
+ }
121
+
122
+ // Every file the MANIFEST says the package contains. These are the references
123
+ // Chrome resolves at load time, so a missing one is not a style question - the
124
+ // package does not load at all.
125
+ //
126
+ // Wildcards are excluded: web_accessible_resources legitimately uses them and a
127
+ // pattern is not a path. Icons are excluded too, deliberately - scan() only
128
+ // reads text files, so an icon that is present would be invisible to the check
129
+ // and would report as missing on every package in the world.
130
+ const norm = (p) => String(p).split(/[\\/]/).join("/").replace(/^\.\//, "").replace(/[?#].*$/, "");
131
+ function declaredFiles(manifest) {
132
+ const m = manifest || {};
133
+ const out = [];
134
+ const add = (v, key) => { if (typeof v === "string" && v && !v.includes("*")) out.push({ path: norm(v), key }); };
135
+ add(m.background?.service_worker, "background.service_worker");
136
+ add(m.background?.page, "background.page");
137
+ for (const s of m.background?.scripts || []) add(s, "background.scripts");
138
+ for (const cs of m.content_scripts || []) {
139
+ for (const j of cs.js || []) add(j, "content_scripts[].js");
140
+ for (const c of cs.css || []) add(c, "content_scripts[].css");
141
+ }
142
+ add(m.action?.default_popup, "action.default_popup");
143
+ add(m.browser_action?.default_popup, "browser_action.default_popup");
144
+ add(m.page_action?.default_popup, "page_action.default_popup");
145
+ add(m.options_page, "options_page");
146
+ add(m.options_ui?.page, "options_ui.page");
147
+ add(m.devtools_page, "devtools_page");
148
+ add(m.side_panel?.default_path, "side_panel.default_path");
149
+ for (const [k, v] of Object.entries(m.chrome_url_overrides || {})) add(v, `chrome_url_overrides.${k}`);
150
+ for (const p of m.sandbox?.pages || []) add(p, "sandbox.pages");
151
+ return out;
152
+ }
153
+
154
+ // A file the scanner SKIPPED is present - it was too big or unreadable, not
155
+ // absent. Counting it as missing would be the exact inversion this codebase
156
+ // keeps warning about: an instrument failure rendering as a finding.
157
+ const missingCache = new WeakMap();
158
+ function missingDeclaredOf(manifest, files, skipped = []) {
159
+ if (missingCache.has(files)) return missingCache.get(files);
160
+ const present = new Set([...files.map((f) => norm(f.path)), ...skipped.map((s) => norm(s.path))]);
161
+ const v = declaredFiles(manifest).filter((d) => !present.has(d.path));
162
+ missingCache.set(files, v);
163
+ return v;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export const RULES = [
169
+
170
+ // FIRST, BECAUSE IT CHANGES HOW YOU READ EVERY OTHER FINDING. If this fires,
171
+ // the directory is not the thing Chrome would review, and every rule that
172
+ // reasons from the ABSENCE of something is unreliable against it.
173
+ //
174
+ // This is a warn by the letter of the contract above: the condition requires
175
+ // something this tool cannot see, namely the build output. Measured on real
176
+ // repositories rather than imagined - 14 of 25 public MV3 projects hold no
177
+ // loadable extension at the path a reader would try first, and the one that
178
+ // reached this rule (refined-github) was failed for two permissions whose
179
+ // call sites are inside dependencies that are not in the repository.
180
+ {
181
+ id: "unbuilt-source",
182
+ // NO CATEGORY, DELIBERATELY. Every other rule names a policy category, which
183
+ // attaches Google's own words and a link to the matching rejection page.
184
+ // This finding is about whether this tool is looking at the right directory,
185
+ // not about anything Google requires, and citing a policy at it would be the
186
+ // paraphrase this file's header forbids.
187
+ run({ manifest, files }) {
188
+ if (!manifest) return [];
189
+ const bare = bareOf(files);
190
+ if (!bare.length) return [];
191
+ const specs = [...new Set(bare.map((b) => b.spec))].slice(0, 5);
192
+ return [finding({
193
+ severity: "warn",
194
+ title: `This looks like source, not a packaged extension: ${bare.length >= 25 ? "25+" : bare.length} bare module import(s)`,
195
+ detail:
196
+ `Files here import module names Chrome cannot resolve (${specs.join(", ")}). A loaded extension can only ` +
197
+ "import a relative path, so this directory is what a bundler reads rather than what it produces, and it " +
198
+ "would not run if you submitted it. Point this tool at your BUILD OUTPUT instead - commonly dist/ or " +
199
+ "build/, .output/chrome-mv3 under wxt, build/chrome-mv3-prod under Plasmo. Until you do, treat every " +
200
+ "finding below with care and the absence of a finding with more: the code that would have triggered it " +
201
+ "may be in a dependency that is not in this directory.",
202
+ evidence: bare.slice(0, 5).map((b) => ({ file: b.file, line: b.line, match: b.spec, text: b.text })),
203
+ })];
204
+ },
205
+ },
206
+
207
+ // SECOND, AND FOR THE SAME REASON AS unbuilt-source: it decides whether the
208
+ // findings under it are about a package at all. That rule catches a source
209
+ // tree by its imports; this one catches a MANIFEST FRAGMENT, which has no
210
+ // imports to give it away and which the import test therefore walks straight
211
+ // past.
212
+ //
213
+ // `name` and `version` are the two keys Chrome requires of every manifest, so
214
+ // a file missing either cannot load - it is not a strict manifest that needs
215
+ // fixing, it is an input a build step merges into one. Measured on real
216
+ // repositories rather than imagined: darkreader ships src/manifest.json at MV2
217
+ // plus src/manifest-chrome-mv3.json holding ONLY the MV3 deltas (no name, no
218
+ // version, no description), and automa ships src/manifest.chrome.json with a
219
+ // name but no version - both assemble the real thing at build time.
220
+ //
221
+ // What this replaces is the reason it exists. Both of those repositories were
222
+ // being told "manifest.json has no description" at severity FAIL, which is a
223
+ // true sentence about the file and a wrong diagnosis of the situation: it
224
+ // sends someone to add a description to a fragment that is not supposed to
225
+ // have one, and it never mentions the two keys whose absence actually matters.
226
+ //
227
+ // WARN, not fail, on the same contract as unbuilt-source: distinguishing "a
228
+ // fragment" from "a manifest with a real mistake in it" needs the build output,
229
+ // which this tool cannot see.
230
+ {
231
+ id: "incomplete-manifest",
232
+ // NO CATEGORY, deliberately, exactly as unbuilt-source. This is a finding
233
+ // about where the tool is pointed, not about anything Google requires, and
234
+ // attaching a policy quote to it would be a paraphrase of a rule that does
235
+ // not cover this.
236
+ run({ manifest }) {
237
+ if (!manifest) return [];
238
+ const missing = ["name", "version"].filter((k) => !String(manifest[k] ?? "").trim());
239
+ if (!missing.length) return [];
240
+ return [finding({
241
+ severity: "warn",
242
+ title: `manifest.json declares no ${missing.join(" and no ")}, so this is a build fragment rather than a package Chrome can load`,
243
+ detail:
244
+ `Chrome requires ${missing.join(" and ")} in every manifest and refuses to load a package without ` +
245
+ "them, so a file missing them is almost always one input to a build step that merges several manifests " +
246
+ "into the real one - a chrome-only variant, or a base shared with Firefox. Point this tool at your BUILD " +
247
+ "OUTPUT, or at the unzipped package you would upload. Until you do, treat every finding below with care " +
248
+ "and the ABSENCE of a finding with more: checks that read your name, version, description or icons are " +
249
+ "reading a file that was never meant to hold them.",
250
+ evidence: missing.map((k) => ({ file: "manifest.json", line: 1, text: `${k} absent` })),
251
+ })];
252
+ },
253
+ },
254
+
255
+ // THIRD AIMING RULE, and the sharpest of the three because it is not a
256
+ // heuristic at all: Chrome resolves these paths at load time, so a package
257
+ // missing one does not load. If the files the manifest names are not here,
258
+ // this is not the package.
259
+ //
260
+ // It exists because two extensions were failed at severity FAIL for
261
+ // permissions "never used" in code that was not in the directory:
262
+ //
263
+ // Authenticator-Extension/Authenticator - the walker staged
264
+ // manifests/manifest-chrome.json, a directory of SEVEN JSON FILES AND NO
265
+ // CODE. We told a 2FA extension to delete storage, identity, alarms,
266
+ // scripting and contextMenus having read none of its source. Its manifest
267
+ // names dist/background.js and view/popup.html, neither of which is there.
268
+ //
269
+ // uddin-rajaul/Neko-Tab - public/ holds the manifest, the icons and two
270
+ // scripts; the new tab page it declares (index.html) is built from src/.
271
+ // topSites, identity and history are used by that page.
272
+ //
273
+ // unbuilt-source cannot catch either: it looks for bare module imports, and
274
+ // there is barely any code here to import anything. Absence of code reads
275
+ // exactly like absence of a violation, which is the failure mode this whole
276
+ // file keeps circling.
277
+ //
278
+ // WARN, on the same contract as the other two aiming rules. If the file really
279
+ // is missing from the package you upload, Chrome refuses it outright and this
280
+ // is the most important line in the output - but telling that apart from
281
+ // "pointed at the wrong directory" needs the build output we cannot see.
282
+ {
283
+ id: "missing-declared-files",
284
+ // NO CATEGORY, as with unbuilt-source and incomplete-manifest.
285
+ run({ manifest, files, skipped = [] }) {
286
+ if (!manifest) return [];
287
+ const missing = missingDeclaredOf(manifest, files, skipped);
288
+ if (!missing.length) return [];
289
+ const names = missing.slice(0, 3).map((d) => d.path).join(", ");
290
+ return [finding({
291
+ severity: "warn",
292
+ title: `manifest.json names ${missing.length} file(s) that are not in this directory: ${names}${missing.length > 3 ? ", ..." : ""}`,
293
+ detail:
294
+ "Chrome resolves these paths when it loads the package, so if they are still missing when you upload, " +
295
+ "it is refused before any reviewer sees it. Far more often it means this directory is not the package - " +
296
+ "the files are produced by a build, or the manifest is one of several kept together in a folder of their " +
297
+ "own. Point this tool at your BUILD OUTPUT or at the unzipped package you would upload. Until you do, " +
298
+ "treat the ABSENCE of a finding with particular care: checks that look for where a permission is used " +
299
+ "are reading code that is not here.",
300
+ evidence: missing.slice(0, 6).map((d) => ({ file: "manifest.json", line: 1, text: `${d.key}: ${d.path}` })),
301
+ })];
302
+ },
303
+ },
304
+
305
+ {
306
+ id: "manifest-v2",
307
+ category: "additional-requirements-for-manifest-v3",
308
+ run({ manifest }) {
309
+ if (!manifest || manifest.manifest_version === 3) return [];
310
+ return [finding({
311
+ severity: "fail",
312
+ title: `manifest_version is ${manifest.manifest_version ?? "missing"}, and the Chrome Web Store accepts Manifest V3 only`,
313
+ detail:
314
+ "Manifest V2 submissions are no longer accepted. This is not a policy judgement call, it is a " +
315
+ "hard gate before any reviewer sees the extension.",
316
+ evidence: [{ file: "manifest.json", line: 1, text: `"manifest_version": ${manifest.manifest_version ?? "absent"}` }],
317
+ })];
318
+ },
319
+ },
320
+
321
+ {
322
+ id: "remote-code",
323
+ category: "additional-requirements-for-manifest-v3",
324
+ run({ manifest, files, skipped = [], oversized = [] }) {
325
+ const out = [];
326
+ // A TEST FILE IS NOT THE PACKAGE CHROME REVIEWS, and pretending otherwise
327
+ // was this rule's largest source of wrong FAILs. Measured over 94 packages
328
+ // from 160 public repos: of 20 remote-code findings, THREE cited nothing
329
+ // but test code - Nagi-ovo/voyager was failed on one line in
330
+ // `__tests__/preventAutoScrollScript.test.ts` and nothing else - and five
331
+ // more buried their real site in test noise (scriptcat 13 of 15 sites,
332
+ // TTV-AB 12 of 13, pie-ai-agent 6 of 7).
333
+ //
334
+ // Test files are what a build excludes; a developer who has to scroll past
335
+ // twelve of them to find the one line that matters is the developer who
336
+ // stops running the tool. So the sites are PARTITIONED, never deleted: a
337
+ // package that really does ship its tests still gets told, just not at
338
+ // FAIL. Silence is the failure mode this repo keeps punishing.
339
+ const looksLikeTest = (f) =>
340
+ /(^|\/)(__tests__|__test__|test|tests|spec|__mocks__|e2e|cypress)(\/|$)/i.test(f) ||
341
+ /\.(test|spec)\.[cm]?[jt]sx?$/i.test(f) ||
342
+ /(^|\/)test-[^/]*\.[cm]?[jt]sx?$/i.test(f);
343
+ // Named in the FAIL text so the count a developer sees adds up. Without
344
+ // this, evidence would list sites the title never accounts for.
345
+ const testNote = (tests) => tests.length
346
+ ? ` A further ${tests.length} site(s) are in test files, listed after the ones above; a build normally excludes those.`
347
+ : "";
348
+ // Shipped sites first so the line that matters is the line they read.
349
+ const split = (hits, mk) => {
350
+ if (!hits.length) return;
351
+ const tests = hits.filter((h) => looksLikeTest(h.file));
352
+ const shipped = hits.filter((h) => !looksLikeTest(h.file));
353
+ out.push(mk(shipped, tests));
354
+ };
355
+ // [^>] already crosses newlines - a JS character class ignores line
356
+ // structure - so the pattern was never the problem and must NOT be widened
357
+ // to [\s\S]: that would run past the closing > of this tag and match a
358
+ // remote src on some later <img>. The bug was that grep() fed it one line
359
+ // at a time, so it was never shown a newline to cross.
360
+ // AN HTML FILE NO EXTENSION PAGE CAN OPEN IS A WEBSITE, NOT A VIOLATION.
361
+ // Rat-S/ai-chat-exporter is FAILed on three sites and all three are its
362
+ // GitHub Pages site: `docs/404.html` loads cdn.tailwindcss.com and two
363
+ // feedback pages embed a Tally widget. Nothing in that manifest references
364
+ // `docs/`; the package ships `popup/`, `content/`, `background/`,
365
+ // `options/`, `sidepanel/` and `schemas/`. So the highest-severity finding
366
+ // this tool produces was 100% wrong on a real extension, and a developer
367
+ // who deletes a CDN tag from their marketing site to satisfy us has been
368
+ // actively misled. Found by auditing the low-star band the s106 sampling
369
+ // fix added - a population the old 35-star corpus contained none of, and
370
+ // the one that actually gets rejected.
371
+ //
372
+ // WHY THIS IS DECIDABLE FOR MARKUP AND NOT FOR CODE. A JS file can be
373
+ // pulled in by any dynamic import or bundler chunk, so "unreachable" is
374
+ // not provable. An HTML file only executes when something OPENS it, and
375
+ // the manifest is where an extension declares its pages. That asymmetry is
376
+ // why this partition is applied to the <script src> scan alone.
377
+ //
378
+ // THE DIRECTORY, NOT THE FILE, is the unit, and deliberately so. An
379
+ // extension may open `popup/help.html` with `chrome.tabs.create` without
380
+ // ever naming it in the manifest, and that page is real. But `popup/` is a
381
+ // directory the manifest DOES reference, so it stays shipped. A directory
382
+ // the manifest references nothing in at all is the project's website,
383
+ // its documentation or its CI - not its page graph.
384
+ //
385
+ // AND IT PARTITIONS, IT DOES NOT DELETE - the same rule the test split
386
+ // follows for the same reason. Every site is still listed and still named;
387
+ // it just cannot carry FAIL on its own. Silence is the failure mode this
388
+ // repo keeps punishing.
389
+ // THE BASENAME IS CHECKED TOO, AND A REAL MISS IS WHY. The first version of
390
+ // this partition compared source-tree paths against a manifest that
391
+ // describes the BUILT layout, and demoted a genuine violation in
392
+ // google/archat: its manifest declares `options_ui.page: "options.html"`
393
+ // while the repository keeps that page at `options/options.html` and the
394
+ // rollup config flattens it on build. The directory `options/` is named
395
+ // nowhere in the manifest, so a directory-only test called Google's own
396
+ // extension a website and walked past a Google Tag Manager <script> in it.
397
+ // Caught by A/B-ing the change over the cached SOURCE trees - the release
398
+ // A/B could not have caught it, because a built zip has no `options/` to
399
+ // flatten. A basename match resolves the ambiguity toward FAIL, which is
400
+ // the safe direction for the highest-severity rule in the tool.
401
+ const liveDirs = new Set([""]);
402
+ const liveNames = new Set();
403
+ const collectRefs = (v) => {
404
+ if (typeof v === "string") {
405
+ if (/^(https?:|data:|\/\/)/i.test(v) || !/\.[a-z0-9]{2,5}$/i.test(v)) return;
406
+ const p = v.replace(/^\.?\//, "");
407
+ // Every ancestor of a referenced file is part of the page graph:
408
+ // `content/lib/*.js` makes both `content/lib` and `content` live.
409
+ const parts = p.split("/");
410
+ liveNames.add(parts[parts.length - 1].toLowerCase());
411
+ for (let i = parts.length - 1; i >= 0; i--) liveDirs.add(parts.slice(0, i).join("/"));
412
+ return;
413
+ }
414
+ if (Array.isArray(v)) { v.forEach(collectRefs); return; }
415
+ if (v && typeof v === "object") { Object.values(v).forEach(collectRefs); }
416
+ };
417
+ collectRefs(manifest || {});
418
+ const offPackage = (f) => {
419
+ const i = f.lastIndexOf("/");
420
+ if (liveNames.has(f.slice(i + 1).toLowerCase())) return false;
421
+ return !liveDirs.has(i < 0 ? "" : f.slice(0, i));
422
+ };
423
+
424
+ const remoteScriptAll = grepAcross(files, /<script[^>]+src\s*=\s*["'](https?:)?\/\/[^"']+/i, isMarkup);
425
+ const remoteScript = remoteScriptAll.filter((h) => !offPackage(h.file) || looksLikeTest(h.file));
426
+ const website = remoteScriptAll.filter((h) => offPackage(h.file) && !looksLikeTest(h.file));
427
+ // Emitted whenever there are any, NOT only when nothing else fired. Gating
428
+ // it on "no other sites" is how a partition quietly becomes a deletion:
429
+ // a package with one real violation and three website hits would report
430
+ // the one and drop the three without saying so.
431
+ if (website.length) {
432
+ out.push(finding({
433
+ severity: "warn",
434
+ title: `A <script> tag loads remote code, but only in pages your manifest never opens (${website.length} site(s))`,
435
+ detail:
436
+ "Every site is in a directory your manifest references nothing in - typically a project website, a docs "
437
+ + "folder or CI. Chrome only reviews what you upload, and a page no extension surface can open cannot "
438
+ + "run, which is why this is not a failure. Check it against the zip you actually upload: if these files "
439
+ + "DO ship and a page of yours opens one, it is the real violation and Google names it as the first "
440
+ + "trigger for this category.",
441
+ evidence: website,
442
+ }));
443
+ }
444
+ split(remoteScript, (shipped, tests) => shipped.length
445
+ ? finding({
446
+ severity: "fail",
447
+ title: "A <script> tag loads code from outside the extension package",
448
+ detail: "Google names this as the first trigger for this category. The referenced file must be vendored into the package."
449
+ + testNote(tests),
450
+ evidence: [...shipped, ...tests],
451
+ })
452
+ : finding({
453
+ severity: "warn",
454
+ title: `A <script> tag loads remote code, but only in test files (${tests.length} site(s))`,
455
+ detail:
456
+ "Every site is in a file this tool reads as a test. Builds normally exclude those, so this is very "
457
+ + "likely not in the package you upload - which is why it is not a failure. Check your build output: "
458
+ + "if these files DO ship, it is the real violation and Google names it as the first trigger for this category.",
459
+ evidence: tests,
460
+ }));
461
+ // eval and the Function constructor. `new Function()` is the one people
462
+ // forget: it is eval wearing a different name and the policy names both.
463
+ //
464
+ // `$` BELONGS IN THE EXCLUSION because it is a valid JavaScript identifier
465
+ // character, so `$eval` is ONE identifier and not a call to eval - exactly
466
+ // as `myeval` is. Without it this fired `fail` on every AngularJS package
467
+ // using `scope.$eval(...)`, which is an Angular expression evaluator and
468
+ // not the JS engine's eval at all. Found by running this rule against real
469
+ // third-party extensions (listen1/listen1_chrome_extension) rather than
470
+ // against our own fixtures, which had no `$`-prefixed identifiers in them.
471
+ //
472
+ // This NARROWS the rule, so the risk it carries is a miss rather than a
473
+ // wrong fail. That is the safe direction here only because `$eval` is not
474
+ // a JS API: there is no real string-execution path this now walks past.
475
+ // COMMENTS BLANKED FIRST, and only comments. Fannon/search-bookmarks was
476
+ // failed on `popup/js/model/validateOptions.js:6`, whose text reads "This
477
+ // is a CSP-safe recursive validator that doesn't require eval() or
478
+ // Function()" - a sentence saying the code does NOT do this, quoted back
479
+ // as proof that it does. A comment cannot execute, so blanking one can
480
+ // never hide a violation.
481
+ //
482
+ // STRING LITERALS ARE DELIBERATELY LEFT ALONE (T-0411). Blanking them
483
+ // would also silence automa, which assembles code inside a template
484
+ // literal and then injects it - a real violation. `stripComments` steps
485
+ // over strings and preserves newlines, so line numbers still line up.
486
+ //
487
+ // TWO SITE SHAPES ARE PROVABLY NOT STRING EXECUTION AND ARE SKIPPED
488
+ // (T-0414, measured on 79 shipped release packages rather than source
489
+ // trees). Both are skipped per MATCH, so the scan continues along the same
490
+ // line - see grep() in scan.mjs for why that distinction is the whole
491
+ // point. Between them they were the SOLE evidence behind 8 of 23 FAILs.
492
+ //
493
+ // 1. `new Function("return this")` is webpack's globalThis shim. A BUNDLER
494
+ // emits it; no developer wrote it and none can delete it without
495
+ // changing bundler config. The argument is an eleven-character literal,
496
+ // so it cannot carry anything from the network - and webpack already
497
+ // wraps it in try/catch precisely because an extension CSP blocks it.
498
+ // Present in 13 of the 23 packages, and the only site in floccus,
499
+ // aniskip, copycat and chatGPTBox. It was also MASKING a real
500
+ // `new Function(scriptText)(window)` in two screenity bundles.
501
+ // 2. `eval(a, b){` is a METHOD DEFINITION NAMED eval, not a call: a call
502
+ // is never followed by a block. less.js declares `eval()` on its AST
503
+ // node classes, which FAILed openstyles/stylus on three sites that are
504
+ // all definitions. The `[^.\w$]` guard cannot see these because the
505
+ // preceding character in minified code is `}`.
506
+ //
507
+ // STRING-BLANKING IS REFUSED, AND THIS IS THE MEASUREMENT RATHER THAN A
508
+ // PREFERENCE (T-0416, settled on the 82 cached release packages).
509
+ //
510
+ // T-0416 proposed blanking plain string literals while sparing template
511
+ // literals that interpolate, so that HackTools' XSS cheat-sheet text and
512
+ // scriptcat's bundled ESLint metadata stop FAILing. Implemented as a
513
+ // probe, that rule reclassified 28 sites - and only FOUR of them are the
514
+ // inert text it was aimed at. Six were REAL violations that a
515
+ // quote-tracking scan had misread, and eighteen were executable source
516
+ // deliberately embedded as a string (ace's worker in dejavu, an injected
517
+ // bundle in nanobrowser, a whole webpack bundle in wechatsync, which
518
+ // executes text fetched from a remote page).
519
+ //
520
+ // THE MECHANISM IS THE PART THAT TRANSFERS. `stripComments` bails a
521
+ // quoted string at a NEWLINE, which bounds the damage of a mispaired
522
+ // quote to one line. A minified bundle is ONE line - screenity's is
523
+ // 462,552 characters with zero newlines - so that bound never applies and
524
+ // a single misread quote reclassifies the rest of the file.
525
+ //
526
+ // THE CONTROL THAT SETTLES IT is inside one package. screenity ships the
527
+ // same bundled library twice, and both copies execute the text of a
528
+ // <script> element they found: `new Function(x[P])(window)` in
529
+ // cloudrecorder.bundle.js and `new Function(k[E])(window)` in
530
+ // contentScript.bundle.js, byte-identical but for minifier variable
531
+ // names. A quote-tracking scan calls the first one CODE and puts the
532
+ // second inside a 93,061-character "string literal". Identical code, two
533
+ // answers - so the scan cannot be a basis for silencing anything, and
534
+ // blanking would have silenced the very violation s101 unmasked.
535
+ //
536
+ // THE DIRECTION OF SAFETY INVERTS WHEN THE SCANNER IS REUSED. Missing a
537
+ // comment leaves code visible, which is why stripComments resolves every
538
+ // ambiguity toward "not a comment". Asking the SAME scanner to blank
539
+ // strings turns each of those misreads into a deleted violation. A
540
+ // fail-safe scanner is only fail-safe for the question it was built for.
541
+ //
542
+ // What IS fixed here is the part that needs no string tracking at all:
543
+ // `eval()` with an empty argument list executes nothing and returns
544
+ // undefined, so it is provably not string execution - the same kind of
545
+ // local, per-match proof as the two skips below. That is scriptcat's two
546
+ // sites, whose text is "Disallow the use of `eval()`". HackTools' two
547
+ // sites are real-looking calls (`eval('ale'+'rt(0)')`) that happen to sit
548
+ // in a string, and nothing local separates them from a genuine call, so
549
+ // they are LEFT FAILING rather than guessed at.
550
+ const BUNDLER_GLOBAL_SHIM = /^new\s+Function\s*\(\s*(["'])return this\1\s*\)/;
551
+ const EVAL_METHOD_DEF = /^eval\s*\(\s*(?:[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)?\s*\)\s*\{/;
552
+ const EVAL_NO_ARGS = /^eval\s*\(\s*\)/;
553
+ const executesAString = (m, line) => {
554
+ // m[1] is the leading non-identifier char of the eval branch, which is
555
+ // part of the match but not part of the site.
556
+ const at = m.index + (m[1] ? m[1].length : 0);
557
+ const tail = line.slice(at, at + 80);
558
+ return !BUNDLER_GLOBAL_SHIM.test(tail) && !EVAL_METHOD_DEF.test(tail)
559
+ && !EVAL_NO_ARGS.test(tail);
560
+ };
561
+ const source = new Map(files.map((f) => [f.path, f.lines]));
562
+ const EVAL_RE = /(^|[^.\w$])eval\s*\(|new\s+Function\s*\(/;
563
+ const evals = grep(codeView(files), EVAL_RE, isCode, executesAString)
564
+ // Report the ORIGINAL line, not the blanked one: evidence a developer
565
+ // reads has to match what is in their editor.
566
+ //
567
+ // RE-EXCERPT IT, do not paste the whole line back. Substituting the raw
568
+ // line here used to discard grep()'s excerpt AND its 160-char cap, which
569
+ // is how a 9,155-character line of malware reached the report rendered as
570
+ // its innocent first 22 characters (`export default config;`, then 507
571
+ // spaces of padding). The excerpt has to follow the match through the
572
+ // substitution, which is what `col` is for.
573
+ .map((h) => {
574
+ const original = source.get(h.file)?.[h.line - 1];
575
+ return original === undefined
576
+ ? h
577
+ : { ...h, text: excerptAround(original, h.col ?? 0, h.match?.length ?? 0) };
578
+ })
579
+ // THE FILES TOO BIG TO HOLD ARE SEARCHED, NOT APOLOGISED FOR (T-0417).
580
+ // Same pattern, same per-match `accept`, same comment blanking - the
581
+ // only difference is that grepLarge slides a window over the file
582
+ // instead of holding it. These are appended rather than reported
583
+ // separately so a 4 MB content script produces the same FAIL as a 4 KB
584
+ // one; the size of the file is the tool's problem, not the developer's.
585
+ .concat(grepLarge(oversized, EVAL_RE, { filter: isCode, code: true, accept: executesAString }));
586
+ split(evals, (shipped, tests) => shipped.length
587
+ ? finding({
588
+ severity: "fail",
589
+ title: "eval() or new Function() executes a string as code",
590
+ detail:
591
+ "Google's trigger is executing a string fetched from a remote source. This tool cannot prove where "
592
+ + "your string comes from, so review each site: if the string is a literal in your own package it is "
593
+ + "defensible, and if any part of it arrives over the network it is the violation."
594
+ + testNote(tests),
595
+ evidence: [...shipped, ...tests],
596
+ })
597
+ : finding({
598
+ severity: "warn",
599
+ title: `eval() or new Function() appears only in test files (${tests.length} site(s))`,
600
+ detail:
601
+ "Every site is in a file this tool reads as a test, and builds normally exclude those, so this is "
602
+ + "very likely not in the package you upload. That is why it is not a failure. Confirm against your "
603
+ + "build output: if these files do ship, review each site as a real remote-code risk.",
604
+ evidence: tests,
605
+ }));
606
+ // SCRIPT LOADING BUILT IN JAVASCRIPT, which the <script src> scan above
607
+ // cannot see because it reads MARKUP only. This is the shape real
608
+ // developers are actually rejected for: the violation is not a tag in
609
+ // their HTML, it is a dependency assembling one at runtime. Found by
610
+ // reading the GitHub issues where rejected developers go - the pain
611
+ // clusters in libraries (mixpanel-js#428, firebase-js-sdk#9114,
612
+ // sentry-javascript#14891), and an extension bundling mixpanel-browser
613
+ // 2.65.0 scored `0 failing` on this tool while carrying
614
+ // cdn.mxpnl.com/libs/mixpanel-recorder.min.js.
615
+ //
616
+ // A VENDOR SIGNATURE LIST WAS WRITTEN AND THEN REFUSED, on the
617
+ // measurement rather than on taste. Of four libraries with documented
618
+ // rejection issues, only mixpanel-browser and firebase still carry a
619
+ // literal remote URL; @sentry/browser builds its CDN path at runtime and
620
+ // @clerk/chrome-extension has no remote loader left at all - it was FIXED
621
+ // between the issue and today. A list keyed on vendor names would have
622
+ // shipped a wrong accusation about Clerk on its first day.
623
+ //
624
+ // TWO PATTERNS, AND THEY ARE SPLIT BY WHAT IS PROVABLE. Both are
625
+ // self-contained single regexes on purpose: grepLarge searches a huge file
626
+ // in independent windows, so any rule needing two facts from opposite ends
627
+ // of a 4.8 MB bundle would silently not work there - and TWO of the four
628
+ // extensions this fires on are over the 2 MB read limit.
629
+ //
630
+ // 1. A remote `.js` URL handed to a src PROPERTY. Requiring the `.js`
631
+ // extension is what keeps the CDN-logo false positive out: an <img>
632
+ // src never ends in .js, so this cannot do what the s075 widening
633
+ // nearly did. TWO SPELLINGS ARE MATCHED AND A TAG USES NEITHER - a dot
634
+ // (`el.src =`) or an object key (`recorder_src:`). That distinction is
635
+ // load-bearing rather than cosmetic: matching a bare `src=` instead
636
+ // swallowed `<script src="...">` into this tier and dragged
637
+ // Rat-S/ai-chat-exporter's generated export back to FAIL, which is the
638
+ // exact wrong answer case 2 exists to avoid. The key form is also the
639
+ // only thing that catches the dependency class at all - mixpanel-browser
640
+ // keeps its loader URL in config as `'recorder_src'` and assigns
641
+ // `script.src = url` from a variable, so a dot-only pattern reports
642
+ // nothing on it.
643
+ // 2. A `<script src=remote>` TAG inside JavaScript is only WARN, because
644
+ // the string may be a document the extension GENERATES for the user
645
+ // rather than one it loads. Rat-S/ai-chat-exporter builds exactly that
646
+ // - a downloadable export with KaTeX headers - and it is the same
647
+ // extension this rule already failed 100% wrongly once, for the
648
+ // neighbouring reason. Statically the two are indistinguishable, so the
649
+ // ambiguity is reported rather than resolved.
650
+ //
651
+ // Measured over 245 real MV3 extension roots before shipping: pattern 1
652
+ // fires on 5 sites in 2 extensions and every one is a true positive - an
653
+ // MV3 content script assigning hcaptcha and recaptcha api.js, and a player
654
+ // config holding a remote hdslb.com script. Pattern 2 fires on 14 sites in
655
+ // 4 extensions, of which three are real and the fourth is the generated
656
+ // export named above, which is why it does not fail. The generic version
657
+ // of this - any remote .js URL in shipped code - was measured FIRST and
658
+ // REFUSED at 288 sites and roughly 70 per cent noise, mostly github.com
659
+ // project links and example.com placeholders in fixtures.
660
+ // NO UNBOUNDED PREFIX BEFORE `src`, and this is a correctness bug rather
661
+ // than a style note. Writing the key form as `[\w$]*src` to allow
662
+ // `recorder_src` backtracks catastrophically: on the 2 MB padding in the
663
+ // oversized fixture the engine retries that star at every offset and the
664
+ // suite hangs instead of failing. No prefix is needed - the scan already
665
+ // finds `src` wherever it sits inside a longer identifier.
666
+ // `.js.gz` IS A SCRIPT AND WAS INVISIBLE HERE (s123). Amplitude serves its
667
+ // libraries pre-compressed - `analytics-browser-2.45.5-min.js.gz` - and
668
+ // their own install snippet assigns exactly that to `c.src`. The old
669
+ // pattern required `.js` immediately before the quote, so every one of
670
+ // those slipped past the highest-severity rule in the tool. Allowing the
671
+ // suffix cannot widen the false-positive surface the `.js` requirement
672
+ // exists to hold back: an <img> src does not end in `.js.gz` either.
673
+ // Measured over the 245 roots and it adds ZERO sites there - the corpus
674
+ // simply contains no compressed script URLs, which is a statement about
675
+ // the corpus and not evidence the shape is rare in the wild.
676
+ const SRC_ASSIGN_RE = /(?:\.\s*src\s*=|src["'`]?\s*:)\s*["'`](https?:)?\/\/[^"'`\s]+\.js(\.gz)?(\?[^"'`\s]*)?["'`]|setAttribute\s*\(\s*["'`]src["'`]\s*,\s*["'`](https?:)?\/\/[^"'`\s]+\.js(\.gz)?(\?[^"'`\s]*)?["'`]/i;
677
+ const srcAssign = grepAcross(files, SRC_ASSIGN_RE, isCode)
678
+ .concat(grepLarge(oversized, SRC_ASSIGN_RE, { filter: isCode }));
679
+ split(srcAssign, (shipped, tests) => shipped.length
680
+ ? finding({
681
+ severity: "fail",
682
+ title: "Code assigns a remote script URL to an element's src",
683
+ detail:
684
+ "This loads and runs JavaScript from outside the package at runtime, which is what Google names "
685
+ + "first for this category. It is often a bundled dependency rather than your own code - check the "
686
+ + "file path below. The referenced script must be vendored into the package."
687
+ + testNote(tests),
688
+ evidence: [...shipped, ...tests],
689
+ })
690
+ : finding({
691
+ severity: "warn",
692
+ title: `Code assigns a remote script URL to an element's src, but only in test files (${tests.length} site(s))`,
693
+ detail:
694
+ "Every site is in a file this tool reads as a test, which a build normally excludes. Confirm against "
695
+ + "your build output: if these files do ship, it is a real remote-code violation.",
696
+ evidence: tests,
697
+ }));
698
+
699
+ // A REMOTE SCRIPT URL HANDED TO A SCRIPT-LOADING CALL. The src-assignment
700
+ // pattern above only sees the moment a URL reaches a `src`, and a library
701
+ // that wraps that in a helper never writes one. Amplitude ships
702
+ //
703
+ // e.loadScriptOnce("https://cdn.amplitude.com/libs/visual-tagging-selector-1.0.0-alpha.js.gz")
704
+ //
705
+ // in @amplitude/analytics-browser, and an MV3 extension bundling it scored
706
+ // `0 failing` on this tool while carrying the single most-documented
707
+ // rejection cause we have found: amplitude/Amplitude-TypeScript#859 names
708
+ // NINE separate rejections, has been open since 2024-08-24, and the URL is
709
+ // still present in 2.45.5 published 2026-07-28.
710
+ //
711
+ // THIS ONE PASSES THE TEST THE tesseract.js RULE FAILED (s122). There the
712
+ // violation was a literal and the defence was a variable, so no static
713
+ // rule could be fair. Here the maintainer-endorsed workaround is to
714
+ // REPLACE THE LITERAL with "" at build time, so a compliant build does not
715
+ // carry the string and a violating one does. Violation and compliance are
716
+ // in the same form, which is what makes the literal the deciding fact.
717
+ //
718
+ // VERBS THAT MEAN "MAKE THIS RUN", NOT "GO AND GET THIS". `fetch` and
719
+ // `require` were in the first draft and measured out: they dragged in
720
+ // scriptcat's `fetchScriptBody("https://e.com/x.user.js")` six times, and
721
+ // a userscript manager retrieving script TEXT is not the same act as
722
+ // executing it. Keyed on the call's shape, never on a vendor name (s118).
723
+ //
724
+ // THE LIMIT, STATED RATHER THAN DISCOVERED LATER: this needs the URL as a
725
+ // LITERAL inside the call. In amplitude's unminified ESM build it is not -
726
+ // `constants.js` exports the string and `libs/messenger.js` calls
727
+ // `.loadScriptOnce(AMPLITUDE_VISUAL_TAGGING_SELECTOR_SCRIPT_URL)` with the
728
+ // variable, two files apart. The minifier inlines it, so the form that
729
+ // actually ships reads `e.loadScriptOnce("https://cdn.amplitude.com/...")`
730
+ // and is caught. That is the right side to be correct on, because this
731
+ // tool reads the package you upload rather than your node_modules, but do
732
+ // not read a clean result on unbundled source as coverage.
733
+ //
734
+ // Measured over the 245 real MV3 roots: 3 sites in 2 packages, every one a
735
+ // test fixture on a placeholder domain (example.com, example.invalid), so
736
+ // the all-tests branch below reports them as warn rather than accusing
737
+ // anyone. ZERO sites in shipped code - which means this corpus cannot
738
+ // exercise the rule, and that is a statement about the corpus, not
739
+ // evidence the rule holds (T-0436).
740
+ const LOAD_SCRIPT_CALL_RE = /\b(?:load|inject|append|insert|import)[A-Za-z0-9_$]*script[A-Za-z0-9_$]*\s*\(\s*["'`](https?:)?\/\/[^"'`\s]+\.js(\.gz)?(\?[^"'`\s]*)?["'`]/i;
741
+ const loadCall = grepAcross(files, LOAD_SCRIPT_CALL_RE, isCode)
742
+ .concat(grepLarge(oversized, LOAD_SCRIPT_CALL_RE, { filter: isCode }));
743
+ split(loadCall, (shipped, tests) => shipped.length
744
+ ? finding({
745
+ severity: "fail",
746
+ title: "Code passes a remote script URL to a script-loading call",
747
+ detail:
748
+ "This fetches and runs JavaScript from outside the package at runtime, which is what Google names "
749
+ + "first for this category. It is usually a bundled dependency rather than your own code - check the "
750
+ + "file path below. If the URL is a vendor default you do not use, replacing the string at build "
751
+ + "time is enough; otherwise the script must be vendored into the package."
752
+ + testNote(tests),
753
+ evidence: [...shipped, ...tests],
754
+ })
755
+ : finding({
756
+ severity: "warn",
757
+ title: `Code passes a remote script URL to a script-loading call, but only in test files (${tests.length} site(s))`,
758
+ detail:
759
+ "Every site is in a file this tool reads as a test, which a build normally excludes. Confirm against "
760
+ + "your build output: if these files do ship, it is a real remote-code violation.",
761
+ evidence: tests,
762
+ }));
763
+
764
+ const SCRIPT_TAG_IN_CODE_RE = /<script[^>]+src\s*=\s*["'](https?:)?\/\/[^"']+/i;
765
+ const tagInCode = grepAcross(files, SCRIPT_TAG_IN_CODE_RE, isCode)
766
+ .concat(grepLarge(oversized, SCRIPT_TAG_IN_CODE_RE, { filter: isCode }));
767
+ if (tagInCode.length) {
768
+ const shipped = tagInCode.filter((h) => !looksLikeTest(h.file));
769
+ const tests = tagInCode.filter((h) => looksLikeTest(h.file));
770
+ out.push(finding({
771
+ severity: "warn",
772
+ title: `JavaScript here builds a <script> tag that loads remote code (${tagInCode.length} site(s))`,
773
+ detail:
774
+ "A script tag with a remote src is being assembled as a string in code rather than written in an HTML "
775
+ + "file, so the markup check above cannot see it. Answer one question: does your extension OPEN this "
776
+ + "document, or does it write it out for the user to keep? If the extension opens it - a preview pane, "
777
+ + "an injected iframe, a page it navigates to - this is a real remote-code violation and the script "
778
+ + "must be vendored. If it is an export or a report the user downloads and opens themselves, it runs "
779
+ + "as an ordinary web page with no extension privileges and this is not a violation. That distinction "
780
+ + "cannot be made by reading the file, which is why this is not a failure."
781
+ + (tests.length ? ` ${tests.length} of these site(s) are in test files.` : ""),
782
+ evidence: [...shipped, ...tests],
783
+ }));
784
+ }
785
+
786
+ const DYN_IMPORT_RE = /import\s*\(\s*["'`](https?:)?\/\//;
787
+ const dynImport = grepAcross(files, DYN_IMPORT_RE, isCode)
788
+ // Not comment-blanked, matching grepAcross above: this pattern needs a
789
+ // URL literal, which a prose comment does not carry by accident.
790
+ .concat(grepLarge(oversized, DYN_IMPORT_RE, { filter: isCode }));
791
+ split(dynImport, (shipped, tests) => shipped.length
792
+ ? finding({
793
+ severity: "fail",
794
+ title: "A dynamic import() pulls a module from a remote URL",
795
+ ...(tests.length ? { detail: testNote(tests).trim() } : {}),
796
+ evidence: [...shipped, ...tests],
797
+ })
798
+ : finding({
799
+ severity: "warn",
800
+ title: `A dynamic import() pulls a remote module, but only in test files (${tests.length} site(s))`,
801
+ detail:
802
+ "Every site is in a file this tool reads as a test, which a build normally excludes. Confirm against "
803
+ + "your build output: if these files do ship, it is a real remote-code violation.",
804
+ evidence: tests,
805
+ }));
806
+
807
+ // A FLUTTER WEB BUILD LOADS ITS RENDERER FROM GOOGLE'S CDN BY DEFAULT,
808
+ // and the fact that decides the verdict is a property of the PACKAGE
809
+ // rather than of any line. Found by reading flutter/flutter#84288, where
810
+ // two developers paste verbatim Blue Argon rejections naming
811
+ // `flutter_bootstrap.js` and `flutter.js`.
812
+ //
813
+ // WHY EVERY PATTERN ABOVE MISSES IT, measured rather than predicted. The
814
+ // loader resolves `${base}canvaskit.js` and hands the result to
815
+ // `import()` or to a `script.src`, so the value reaching the sink is a
816
+ // VARIABLE - the class T-0456 refused to widen a shape rule for, because
817
+ // tightening a shape cannot recover a value. Run over the verbatim
818
+ // snippet the rejected developer pasted into that thread, and over the
819
+ // current loader source, this rule reported nothing; the same file with
820
+ // the URL written as a literal FAILs, which is what proves the miss is
821
+ // real and not a broken harness.
822
+ //
823
+ // AND THE URL LITERAL IS NOT THE ANSWER EITHER, which is the part that
824
+ // makes this a rule instead of a signature. `getCanvaskitBaseUrl` in
825
+ // `flutter_js/src/utils.js` carries
826
+ // `https://www.gstatic.com/flutter-canvaskit` UNCONDITIONALLY and is
827
+ // bundled into the prebuilt `flutter.js` that every project ships, so the
828
+ // literal is present in the COMPLIANT build too. Failing on its presence
829
+ // would fail the developers who already did the right thing, which is the
830
+ // wrong alarm this repo keeps refusing - and it is the same trap one rung
831
+ // further on than T-0456: the deciding fact can be missing even when the
832
+ // URL literal IS inside the window you matched.
833
+ //
834
+ // WHAT DECIDES IT IS ALSO IN THE PACKAGE, which is why this is checkable.
835
+ // `flutter build web --no-web-resources-cdn` sets kUseLocalCanvasKitFlag,
836
+ // and `flutter_tools/lib/src/build_system/targets/web.dart` then (a)
837
+ // emits `"useLocalCanvasKit": true` into the buildConfig it writes into
838
+ // `flutter_bootstrap.js` and (b) copies the renderer into `canvaskit/` in
839
+ // the build output. Either one present means the renderer is local; both
840
+ // absent means it is fetched from gstatic at runtime.
841
+ //
842
+ // AMBIGUITY RESOLVES TOWARD COMPLIANT, matching the asymmetry this rule
843
+ // uses everywhere a wrong FAIL would cost more than a miss. The
844
+ // exonerations are checked against `files`, `skipped` AND `oversized`,
845
+ // because a real `canvaskit.js` is several megabytes and lands in the
846
+ // last two rather than the first.
847
+ const FLUTTER_CDN_RE = /https?:\/\/www\.gstatic\.com\/flutter-canvaskit/i;
848
+ const flutterCdn = grepAcross(files, FLUTTER_CDN_RE, isCode)
849
+ .concat(grepLarge(oversized, FLUTTER_CDN_RE, { filter: isCode }));
850
+ if (flutterCdn.length) {
851
+ const allPaths = [...files, ...skipped, ...oversized]
852
+ .map((e) => String(e.path || "").replace(/\\/g, "/"));
853
+ const localCopy = allPaths.some((p) =>
854
+ /(^|\/)canvaskit\/(canvaskit|skwasm|skwasm_heavy|wimp)\.js$/i.test(p));
855
+ // THE VALUE, NEVER THE BARE NAME. Matching `useLocalCanvasKit` on its
856
+ // own would exonerate every package alive: the property READ sits in
857
+ // the bundled loader source, identically in the compliant and the
858
+ // violating build. Only the compliant one carries it with a value.
859
+ const USE_LOCAL_RE = /useLocalCanvasKit["']?\s*:\s*(?:true|!0)\b/;
860
+ // A base URL the developer pinned themselves, to anything that is not
861
+ // another remote origin. Written so that pinning it AT a remote URL
862
+ // cannot exonerate.
863
+ const PINNED_LOCAL_RE = /canvasKitBaseUrl["']?\s*[:=]\s*["'`](?!https?:|\/\/)/;
864
+ const overridden = files.some((f) => USE_LOCAL_RE.test(f.text) || PINNED_LOCAL_RE.test(f.text))
865
+ || grepLarge(oversized, USE_LOCAL_RE).length > 0
866
+ || grepLarge(oversized, PINNED_LOCAL_RE).length > 0;
867
+ if (!localCopy && !overridden) {
868
+ out.push(finding({
869
+ severity: "fail",
870
+ title: "This Flutter web build loads its renderer from Google's CDN at runtime",
871
+ detail:
872
+ "Flutter's web loader fetches canvaskit.js (or skwasm.js) from www.gstatic.com at runtime, which is "
873
+ + "remotely hosted code in a Manifest V3 item. Rebuild with `flutter build web "
874
+ + "--no-web-resources-cdn`: that copies the renderer into a local canvaskit/ folder and records "
875
+ + "useLocalCanvasKit in flutter_bootstrap.js, and this check passes as soon as either is present. "
876
+ + "You will also need `\"content_security_policy\": {\"extension_pages\": \"script-src 'self' "
877
+ + "'wasm-unsafe-eval'; object-src 'self';\"}` in your manifest, because the renderer compiles "
878
+ + "WebAssembly. Note that the URL itself still appears in flutter.js after the fix, so the string "
879
+ + "being present is not what this check is reporting.",
880
+ evidence: flutterCdn,
881
+ }));
882
+ }
883
+ }
884
+
885
+ // "NO REMOTE CODE FOUND" IS A CLAIM ABOUT ALL THE CODE, and this rule was
886
+ // making it about the code it happened to open. Same asymmetry
887
+ // unused-permissions carries above, at a higher severity: scan.mjs skips
888
+ // any file over 2 MB and lists it honestly, and this rule then went silent
889
+ // as though the package were clean.
890
+ //
891
+ // MEASURED ON THE 82 CACHED RELEASE PACKAGES, not argued: 23 skip at least
892
+ // one file for size, 13 of those produced no remote-code failure at all,
893
+ // and in THREE of them an unread file holds a site this rule would have
894
+ // reported. Anarios/return-youtube-dislike ships
895
+ // `new Function('return (' + source + ');')()` in a 4.0 MB content script;
896
+ // nanobrowser has two sites in a 3.8 MB service worker; bitwarden two more
897
+ // in a 3.2 MB background.js. 27 reportable sites sit in unread files
898
+ // across 7 packages. The tool told all three they were clean.
899
+ //
900
+ // The limit itself is left alone deliberately - a bigger number only moves
901
+ // the cliff, and page-assist ships an 8.3 MB chunk. What must not survive
902
+ // is the SILENCE, because a developer reads "no findings" as "nothing
903
+ // there" and this is the rule Google enforces first.
904
+ //
905
+ // WHAT IS LEFT OF THAT GAP after T-0417 is HTML, and only HTML. The
906
+ // JavaScript is now streamed above, so the warning must subtract exactly
907
+ // what was streamed - printing "could not check" about a file this rule
908
+ // did read is the same defect pointing the other way, and it would train
909
+ // someone to go looking by hand for a site the tool already found.
910
+ const streamed = new Set(oversized.filter(isCode).map((o) => o.path));
911
+ const unreadCode = skipped.filter((s) =>
912
+ /\.(js|mjs|cjs|ts|jsx|tsx|html?)$/i.test(s.path || "") && !streamed.has(s.path));
913
+ if (unreadCode.length) {
914
+ out.push(finding({
915
+ severity: "warn",
916
+ title: `${unreadCode.length} file(s) were too large to read, so this rule could not check them`,
917
+ detail:
918
+ "This is a limitation of the scan, not a defect found in your extension. JavaScript over 2 MB is "
919
+ + "searched in a streaming pass, but these files are not, so remote code inside one of them would not "
920
+ + "appear above - treat the result of this rule as covering only the files listed as read. Search these "
921
+ + "yourself for eval(, new Function(, a dynamic import() of a URL, and <script src> pointing off-package.",
922
+ evidence: unreadCode.map((s) => ({ file: s.path, line: 1, text: s.why })),
923
+ }));
924
+ }
925
+ return out;
926
+ },
927
+ },
928
+
929
+ {
930
+ id: "unused-permissions",
931
+ category: "excessive-permissions",
932
+ run({ manifest, files, skipped = [], oversized = [] }) {
933
+ if (!manifest) return [];
934
+ const declared = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])];
935
+ // Comments blanked first. A permission whose only trace is "// we used to
936
+ // call chrome.bookmarks.getTree here" is the COMMONEST real form of an
937
+ // unused permission - the feature was deleted and the note left behind -
938
+ // and reading raw text made this rule blind to exactly that case while
939
+ // audit.mjs reported it. Shared with audit.mjs so the two cannot disagree.
940
+ const view = codeView(files).filter(isCode);
941
+ const code = view.map((f) => f.text).join("\n");
942
+ let unused = declared.filter((p) => {
943
+ const apis = PERMISSION_API[p];
944
+ if (!apis) return false; // unknown permission: say nothing rather than guess
945
+ // A permission with no namespace to look for is not a permission we
946
+ // failed to find. namespaceUsed answers false for an empty pattern list,
947
+ // and false here means FAIL, so without this activeTab would be reported
948
+ // as unused on every extension that declares it - the one permission
949
+ // Google's own advice, and this linter's narrowing advice, tells people
950
+ // to move TO.
951
+ if (NO_NAMESPACE_PERMISSIONS.has(p)) return false;
952
+ // Not a literal `chrome.storage` test: a minifier aliases the namespace
953
+ // and the literal test then reads as "never used". See namespaceUsed.
954
+ if (namespaceUsed(code, apis)) return false;
955
+ // Some permissions are earned by the manifest with no JavaScript at all
956
+ // (a declarativeNetRequest static ruleset, a side_panel path). This rule
957
+ // is a FAIL, so a false one here deletes a working feature.
958
+ return !MANIFEST_EVIDENCE[p]?.(manifest);
959
+ });
960
+ // THE FILES TOO BIG TO HOLD ARE READ HERE TOO, and note which direction
961
+ // that runs in. Finding a namespace in one can only ever REMOVE a
962
+ // permission from this list - it is evidence of use, and use is the
963
+ // fail-safe answer for this rule. Nothing below can add one.
964
+ const largeCode = (oversized || []).filter(isCode);
965
+ for (const f of largeCode) {
966
+ if (!unused.length) break;
967
+ for (const w of largeWindows(f, { code: true })) {
968
+ unused = unused.filter((p) => !namespaceUsed(w.view, PERMISSION_API[p]));
969
+ if (!unused.length) break;
970
+ }
971
+ }
972
+ if (!unused.length) return [];
973
+ // "NEVER USED" IS A CLAIM ABOUT ALL THE CODE, so it may only be made when
974
+ // all the code was read and read in a form where a name survives. Two
975
+ // things break that, and both were found on one real package - the shipped
976
+ // `dist/chrome` of immersive-translate (s098):
977
+ //
978
+ // 1. FILES WE DID NOT OPEN. scan.mjs skips anything over 2 MB and lists
979
+ // what it skipped, honestly. But this rule went on asserting "never"
980
+ // anyway, and on that package the skipped set was popup.js,
981
+ // content_main.js, side-panel.js and options.js - 2.6 to 3.2 MB each,
982
+ // which is to say the entire application. We read the leftovers and
983
+ // failed it for permissions the unread files use on every run.
984
+ // 2. MINIFICATION, even where everything WAS read: a minifier rewrites
985
+ // `chrome` to a one-letter local, so absence of a name is not absence
986
+ // of a call. namespaceUsed catches the common aliases; it cannot
987
+ // catch a computed `chrome[a]`.
988
+ //
989
+ // Either way the honest report is the same finding at the severity the
990
+ // evidence carries - something for the developer to check against their
991
+ // source, not a defect we claim to have found. Silence would be wrong too:
992
+ // a permission left behind by a deleted feature is still the commonest
993
+ // true positive, and it looks exactly like this.
994
+ // Subtract exactly what was streamed and no more. The remaining entries
995
+ // are oversized HTML and genuinely unreadable files, which really were not
996
+ // read.
997
+ const streamed = new Set(largeCode.map((o) => o.path));
998
+ const unread = skipped.filter((s) =>
999
+ /\.(js|mjs|cjs|ts|jsx|tsx|html?)$/i.test(s.path || "") && !streamed.has(s.path));
1000
+ // AND THE ONES WE DID READ STILL COUNT TOWARD (2), which is the half that
1001
+ // matters. Having read the bytes of a bundle is not having seen the name:
1002
+ // over the 82 cached release packages, dropping the unread caveat without
1003
+ // this would have turned 17 permissions in 7 packages into "declared but
1004
+ // never used", among them contextMenus and idle on Bitwarden, which uses
1005
+ // both. Every one is held here, because every oversized file in those
1006
+ // packages runs to between 170,000 and 1,056,768 characters on one line.
1007
+ const minified = [...looksMinified(view), ...looksMinifiedLarge(largeCode)];
1008
+ const bare = bareImports(view);
1009
+ // A DECLARED FILE THAT IS NOT HERE IS THE STRONGEST OF THESE FOUR, and it
1010
+ // was the one missing. The other three all describe code we read but could
1011
+ // not fully follow; this one says a piece of the package is ABSENT, which
1012
+ // absence-of-a-call cannot be distinguished from. Authenticator's manifest
1013
+ // names dist/background.js and we were linting a folder of seven JSON
1014
+ // files - "never used" there is a claim about nothing at all.
1015
+ const gone = missingDeclaredOf(manifest, files, skipped);
1016
+ if (unread.length || minified.length || bare.length || gone.length) {
1017
+ const why = gone.length
1018
+ ? `manifest.json names ${gone.length} file(s) that are not in this directory (${gone[0].key}: ${gone[0].path}), so the code that uses these permissions is not here to be read`
1019
+ : unread.length
1020
+ ? `${unread.length} code file(s) in this package were not read (${unread[0].path}: ${unread[0].why}), so this scan did not see all of your code`
1021
+ : bare.length
1022
+ ? `this directory is pre-build source rather than a packaged extension (${bare[0].file}:${bare[0].line} imports "${bare[0].spec}"), so the code that uses these may be inside a dependency that is not here`
1023
+ : `this package is bundled or minified (${minified[0].file} runs to ${minified[0].longestLine} characters on one line), and a minifier rewrites chrome.* into short local names`;
1024
+ return [finding({
1025
+ severity: "warn",
1026
+ title: `Cannot confirm these permissions are used: ${unused.join(", ")}`,
1027
+ detail:
1028
+ "Google's stated trigger is asking for a permission you do not use. This is a warning and not a " +
1029
+ `failure because ${why}. That makes "unused" and "unseen" indistinguishable here, and deleting a ` +
1030
+ "permission your extension turns out to need breaks the feature it exists for. Check each of these " +
1031
+ "against your SOURCE. If a feature really did go away and the permission stayed, remove it, or move " +
1032
+ "it to optional_permissions and request it at the moment the feature runs.",
1033
+ evidence: unused.map((p) => ({ file: "manifest.json", line: 1, text: `"${p}"` })),
1034
+ })];
1035
+ }
1036
+ return [finding({
1037
+ severity: "fail",
1038
+ title: `Declared but never used: ${unused.join(", ")}`,
1039
+ detail:
1040
+ "Google's stated trigger is asking for a permission you do not use. Each of these is declared in the " +
1041
+ "manifest and its chrome.* namespace appears nowhere in the packaged JavaScript. Remove it, or move it " +
1042
+ "to optional_permissions and request it at the moment the feature runs.",
1043
+ evidence: unused.map((p) => ({ file: "manifest.json", line: 1, text: `"${p}"` })),
1044
+ })];
1045
+ },
1046
+ },
1047
+
1048
+ {
1049
+ id: "broad-host-permissions",
1050
+ category: "excessive-permissions",
1051
+ run({ manifest }) {
1052
+ if (!manifest) return [];
1053
+ const broad = [...new Set(declaredHosts(manifest).filter((h) => BROAD_HOSTS.includes(h)))];
1054
+ if (!broad.length) return [];
1055
+ return [finding({
1056
+ severity: "warn",
1057
+ title: `Access to every site: ${broad.join(", ")}`,
1058
+ detail:
1059
+ "This is the broader-than-the-job-needs half of the same policy. It is a warning rather than a failure " +
1060
+ "because some extensions genuinely need every site. If yours does not, narrow the match patterns. If it " +
1061
+ "does, expect to justify it in the listing, and note that this also pulls you into the user data policies.",
1062
+ evidence: broad.map((h) => ({ file: "manifest.json", line: 1, text: h })),
1063
+ })];
1064
+ },
1065
+ },
1066
+
1067
+ {
1068
+ id: "listing-metadata",
1069
+ category: "no-metadata",
1070
+ run({ manifest, files = [], skipped = [], i18nUnresolved = [] }) {
1071
+ if (!manifest) return [];
1072
+ // EVERY CHECK IN THIS RULE REASONS FROM AN ABSENCE, which makes it the one
1073
+ // rule most easily wrong about a directory that is not the final package.
1074
+ // A fragment is not missing a description, it is a file that never carried
1075
+ // one; incomplete-manifest says so in the words that lead somewhere, and
1076
+ // repeating it here as "no description" at FAIL is the wrong diagnosis
1077
+ // that rule exists to replace.
1078
+ if (!String(manifest.name ?? "").trim() || !String(manifest.version ?? "").trim()) return [];
1079
+
1080
+ // "MISSING" IS A CLAIM ABOUT THE PACKAGE YOU WOULD UPLOAD, so it is a warn
1081
+ // when that package has not been built. Same move, and the same reasoning,
1082
+ // as unused-permissions downgrading "never used" when it could not read
1083
+ // all the code.
1084
+ //
1085
+ // The case that forced it: Nagi-ovo/voyager was failed for a __MSG_extName__
1086
+ // that "does not resolve", on a tree whose messages.json files sit in
1087
+ // src/locales/ and are copied to _locales/ by the build. The sentence is
1088
+ // true of the directory and false of the extension, and it was the highest
1089
+ // severity this tool emits.
1090
+ //
1091
+ // APPLIED AT ONE EXIT ON PURPOSE. This started life at the bottom of the
1092
+ // rule and was dead for the real case: a manifest whose DESCRIPTION is a
1093
+ // placeholder returns early a few lines below, which is precisely the
1094
+ // shape voyager has, so the downgrade never ran on the extension that
1095
+ // motivated it. The unit test passed anyway because its fixture used a
1096
+ // plain description and fell through to the end - a green assertion about
1097
+ // a path the reported case does not take. The corpus is what caught it.
1098
+ const finish = (list) => {
1099
+ // Missing declared files count for the same reason bare imports do, and
1100
+ // more strongly: Authenticator's _locales really is elsewhere, because
1101
+ // the directory we were handed holds nothing but manifests.
1102
+ if (!list.length || (!bareOf(files).length && !missingDeclaredOf(manifest, files, skipped).length)) return list;
1103
+ return list.map((f) => (f.severity !== "fail" ? f : {
1104
+ ...f,
1105
+ severity: "warn",
1106
+ detail: `${f.detail ? `${f.detail} ` : ""}Reported as a warning rather than a failure because this ` +
1107
+ "directory looks like source rather than a built package (see unbuilt-source above), and a build step " +
1108
+ "commonly supplies exactly these files - _locales/ is frequently copied in from elsewhere in the tree. " +
1109
+ "Re-run against your build output to get a definite answer.",
1110
+ }));
1111
+ };
1112
+
1113
+ const out = [];
1114
+ // A __MSG_ placeholder that resolves to nothing is not a short description,
1115
+ // it is a package that does not load - Chrome rejects it before a reviewer
1116
+ // reads a word of it, the same shape as manifest-v2 above. Reported first
1117
+ // and separately, because "the description is 22 characters" is a wrong
1118
+ // diagnosis of it and sends the developer to edit the wrong file.
1119
+ if (i18nUnresolved.length) {
1120
+ out.push(finding({
1121
+ severity: "fail",
1122
+ title: `A localised manifest field does not resolve: ${i18nUnresolved[0].why}`,
1123
+ detail:
1124
+ "Localised fields are written as __MSG_name__ in manifest.json and the text lives in " +
1125
+ "_locales/<default_locale>/messages.json. Chrome substitutes them at load time, so a placeholder " +
1126
+ "with no matching message leaves the field empty and the package is rejected before review. " +
1127
+ "Until this is fixed, every check in this tool that reads your name or description is reading the " +
1128
+ "placeholder rather than your words.",
1129
+ evidence: i18nUnresolved.map((u) => ({ file: "manifest.json", line: 1, text: u.why })),
1130
+ }));
1131
+ }
1132
+ const desc = (manifest.description || "").trim();
1133
+ if (/__MSG_[A-Za-z0-9_@]+__/.test(desc)) return finish(out); // unresolved: nothing honest to measure
1134
+ if (!desc) {
1135
+ out.push(finding({
1136
+ severity: "fail",
1137
+ title: "manifest.json has no description",
1138
+ evidence: [{ file: "manifest.json", line: 1, text: "description absent" }],
1139
+ }));
1140
+ } else if (desc.length < 25) {
1141
+ out.push(finding({
1142
+ severity: "warn",
1143
+ title: `The description is ${desc.length} characters, which is unlikely to describe a single purpose`,
1144
+ detail: "The reviewer checks the listing against what the extension does. A description too short to state a purpose cannot be checked against anything.",
1145
+ evidence: [{ file: "manifest.json", line: 1, text: desc }],
1146
+ }));
1147
+ }
1148
+ const icons = manifest.icons || {};
1149
+ if (!Object.keys(icons).length) {
1150
+ out.push(finding({
1151
+ severity: "warn",
1152
+ title: "manifest.json declares no icons",
1153
+ detail: "A missing icon is one of the listed triggers. Store listing images are set in the developer dashboard and cannot be checked from the package.",
1154
+ evidence: [{ file: "manifest.json", line: 1, text: "icons absent" }],
1155
+ }));
1156
+ }
1157
+ return finish(out);
1158
+ },
1159
+ },
1160
+
1161
+ {
1162
+ id: "insecure-transmission",
1163
+ category: "udp-secure",
1164
+ run({ files }) {
1165
+ // localhost over http is normal in development and is not a finding.
1166
+ // Comments are blanked first for the same reason unused-permissions blanks
1167
+ // them: "// old endpoint was http://api.example.com" is a URL the package
1168
+ // never contacts, and reporting it sends someone hunting for a call that
1169
+ // is not there. Markup is passed through untouched by codeView, so an
1170
+ // http:// src inside an HTML comment is still reported - that one is worth
1171
+ // keeping, because a commented-out <script src> is a byte away from live.
1172
+ // XML NAMESPACE URIs ARE IDENTIFIERS, NOT ENDPOINTS. `<svg xmlns=
1173
+ // "http://www.w3.org/2000/svg">` and `createElementNS("http://www.w3.org/
1174
+ // 2000/svg", ...)` never touch the network - the string is a name, and the
1175
+ // http:// spelling is fixed by the specification, so it cannot be "fixed"
1176
+ // to https even in principle. Reporting it told people to change a string
1177
+ // that would break their SVG if they changed it.
1178
+ //
1179
+ // This was the single most common warning the tool produced against real
1180
+ // extensions: 6 of 7 insecure-transmission hits across the corpus, in
1181
+ // darkreader, automa, screenity, voyager and return-youtube-dislike. Every
1182
+ // one was this namespace. Our own fixtures contained no inline SVG, which
1183
+ // is why every test passed while the rule was wrong on real code.
1184
+ //
1185
+ // The list is exact prefixes of specification-defined namespaces, not a
1186
+ // blanket w3.org exclusion: a plain-http link to a w3.org *document* is
1187
+ // still a plain-http link and stays reported.
1188
+ const XML_NAMESPACES = [
1189
+ "http://www.w3.org/2000/svg",
1190
+ "http://www.w3.org/1999/xhtml",
1191
+ "http://www.w3.org/1999/xlink",
1192
+ "http://www.w3.org/2000/xmlns/",
1193
+ "http://www.w3.org/XML/1998/namespace",
1194
+ "http://www.w3.org/1998/Math/MathML",
1195
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
1196
+ "http://www.w3.org/2001/XMLSchema",
1197
+ ];
1198
+ const hits = grep(
1199
+ codeView(files),
1200
+ /["'`]http:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[^"'`\s]+/i,
1201
+ (f) => isCode(f) || isMarkup(f),
1202
+ ).filter((h) => {
1203
+ const url = String(h.match || "").replace(/^["'`]/, "");
1204
+ if (XML_NAMESPACES.some((ns) => url.startsWith(ns))) return false;
1205
+ // DOCTYPE system identifiers are the same case as namespaces and were
1206
+ // missed by the first pass: `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//
1207
+ // EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` names a
1208
+ // grammar, is fixed by the specification, and is not a channel anything
1209
+ // travels over. Found still firing on listen1 by running the PUBLISHED
1210
+ // tool after shipping the namespace fix, which is the only reason it was
1211
+ // caught - the local corpus had already gone quiet.
1212
+ //
1213
+ // Deliberately narrow: a `.dtd` under w3.org, not all of w3.org/TR,
1214
+ // which is where specification *documents* live. A plain-http link to a
1215
+ // document is still a plain-http link and stays reported.
1216
+ if (/^https?:\/\/(www\.)?w3\.org\/[^"'`\s]*\.dtd$/i.test(url)) return false;
1217
+ return true;
1218
+ });
1219
+ if (!hits.length) return [];
1220
+ return [finding({
1221
+ severity: "warn",
1222
+ title: "Plain http:// endpoints appear in the package",
1223
+ detail:
1224
+ "The policy is about sending user data over an insecure channel. This tool cannot tell whether user " +
1225
+ "data flows to these URLs, so check each one. Anything carrying user data must be https.",
1226
+ evidence: hits,
1227
+ })];
1228
+ },
1229
+ },
1230
+
1231
+ {
1232
+ id: "crypto-mining",
1233
+ category: "cryptocurrency-mining",
1234
+ run({ files }) {
1235
+ // Comments blanked: this is a FAIL, and accusing someone of shipping a
1236
+ // miner on the strength of "// we removed the coinhive experiment" is the
1237
+ // expensive direction. Code in a comment does not mine anything.
1238
+ const hits = grep(codeView(files), /coinhive|cryptonight|cryptoloot|webminerpool|minergate|\bcoinimp\b/i, (f) => isCode(f) || isMarkup(f));
1239
+ if (!hits.length) return [];
1240
+ return [finding({
1241
+ severity: "fail",
1242
+ title: "Cryptocurrency mining code or a known mining service appears in the package",
1243
+ evidence: hits,
1244
+ })];
1245
+ },
1246
+ },
1247
+
1248
+ {
1249
+ id: "obfuscation",
1250
+ category: "obfuscation",
1251
+ run({ files }) {
1252
+ const out = [];
1253
+ // The javascript-obfuscator signature. Minification is explicitly allowed
1254
+ // by Google, so line length alone is NOT reported: a webpack bundle is one
1255
+ // long line and is perfectly legal.
1256
+ for (const f of files.filter(isCode)) {
1257
+ // CONCEALMENT BY POSITION, which the _0x signature below does not see.
1258
+ // Found because the one piece of live malware this project has ever
1259
+ // caught (a repo in the scan corpus) hid 8.6 KB of loader after 507
1260
+ // spaces on the same line as a legitimate `export default config`. In an
1261
+ // editor and in a GitHub diff the line reads as normal; the payload is
1262
+ // simply off the right-hand edge. That sample scored ZERO on every rule
1263
+ // we had except remote-code, which caught the trailing eval( rather than
1264
+ // the concealment.
1265
+ //
1266
+ // THE THRESHOLD IS MEASURED, NOT GUESSED. Across 48,917 code files in
1267
+ // 287 corpus repos the longest mid-line whitespace run in legitimate
1268
+ // code is 131 (SVG path data in generated octicon components); the
1269
+ // malware is 507; the band between is EMPTY. 200 sits in that gap, so
1270
+ // this fired once in 48,917 files and that once was the malware.
1271
+ //
1272
+ // Anchored on \S both sides: indentation is at line START and is not
1273
+ // this. And it reads f.text RAW on purpose - codeView() blanks comments
1274
+ // by overwriting them with spaces of equal length, so running this over
1275
+ // a code view would manufacture the signal out of any long comment.
1276
+ const RUN = 200;
1277
+ const hidden = new RegExp(`\\S[ \\t]{${RUN},}\\S`, "g");
1278
+ let m;
1279
+ while ((m = hidden.exec(f.text))) {
1280
+ const gap = m[0].length - 2;
1281
+ const line = f.text.slice(0, m.index).split("\n").length;
1282
+ const after = f.text.slice(m.index + m[0].length - 1, m.index + m[0].length + 119);
1283
+ out.push(finding({
1284
+ severity: "fail",
1285
+ title: `${f.path} hides code behind ${gap} spaces on line ${line}`,
1286
+ detail:
1287
+ "There is a run of whitespace on this line long enough that everything after it is off the " +
1288
+ "right-hand edge in an editor, a diff and a code review, and then the line continues with more " +
1289
+ "code. Formatting does not produce this and minifiers strip whitespace rather than add it. " +
1290
+ "Open the line and read past the gap: if you did not put that code there, treat the package as " +
1291
+ "compromised and check what else changed in the same commit.",
1292
+ evidence: [{ file: f.path, line, text: `${gap} spaces, then: ${after.trim().slice(0, 110)}` }],
1293
+ }));
1294
+ hidden.lastIndex = m.index + m[0].length;
1295
+ }
1296
+ const hexNames = (f.text.match(/_0x[0-9a-f]{4,}/gi) || []).length;
1297
+ if (hexNames >= 10) {
1298
+ out.push(finding({
1299
+ severity: "fail",
1300
+ title: `${f.path} carries ${hexNames} _0x-style identifiers, the signature of an obfuscator`,
1301
+ detail:
1302
+ "Google draws the line explicitly: minification is fine, obfuscation is not. Ship the minified " +
1303
+ "bundle, not the obfuscated one, and keep the build reproducible from the source you can show.",
1304
+ evidence: [{ file: f.path, line: 1, text: `${hexNames} occurrences of _0x[hex]` }],
1305
+ }));
1306
+ }
1307
+ }
1308
+ return out;
1309
+ },
1310
+ },
1311
+
1312
+ {
1313
+ id: "keyword-stuffing",
1314
+ category: "keyword-stuffing",
1315
+ run({ manifest }) {
1316
+ if (!manifest?.description) return [];
1317
+ const desc = manifest.description;
1318
+ const words = desc.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) || [];
1319
+ const counts = {};
1320
+ for (const w of words) counts[w] = (counts[w] || 0) + 1;
1321
+ const repeated = Object.entries(counts)
1322
+ .filter(([w, n]) => n >= 4 && !STOPWORDS.has(w))
1323
+ .map(([w, n]) => `${w} x${n}`);
1324
+ const commas = (desc.match(/,/g) || []).length;
1325
+ const out = [];
1326
+ if (repeated.length) {
1327
+ out.push(finding({
1328
+ severity: "warn",
1329
+ title: `The description repeats terms: ${repeated.join(", ")}`,
1330
+ evidence: [{ file: "manifest.json", line: 1, text: desc.slice(0, 160) }],
1331
+ }));
1332
+ }
1333
+ if (commas >= 8 && words.length / Math.max(commas, 1) < 4) {
1334
+ out.push(finding({
1335
+ severity: "warn",
1336
+ title: "The description reads as a comma-separated keyword list rather than prose",
1337
+ detail: "Padding with site lists or locations to game search ranking is the named trigger.",
1338
+ evidence: [{ file: "manifest.json", line: 1, text: desc.slice(0, 160) }],
1339
+ }));
1340
+ }
1341
+ return out;
1342
+ },
1343
+ },
1344
+
1345
+ {
1346
+ id: "ntp-override",
1347
+ category: "circumvents-api-ntp",
1348
+ run({ manifest, files }) {
1349
+ if (!manifest) return [];
1350
+ const overrides = manifest.chrome_url_overrides || {};
1351
+ // ONLY chrome_url_overrides.newtab excuses this, and the narrowing is the
1352
+ // whole point of the rule. It used to accept chrome_settings_overrides
1353
+ // .homepage or .search_provider as well - but those govern the HOMEPAGE and
1354
+ // the OMNIBOX, which are different surfaces with their own override APIs.
1355
+ // The effect was that an extension hijacking the New Tab Page from code
1356
+ // could silence a FAIL by declaring an unrelated homepage override, which
1357
+ // is the manoeuvre the policy exists to catch. Confirmed by running two
1358
+ // packages whose code was byte-identical: adding a homepage key turned
1359
+ // "1 failing" into "0 failing".
1360
+ //
1361
+ // What the detection below actually looks for is newtab and nothing else,
1362
+ // so the declaration that answers it must be newtab and nothing else. If a
1363
+ // future version detects omnibox or homepage manipulation, it needs its own
1364
+ // hits list paired with its own key - not a shared any-of-three flag.
1365
+ const declared = Boolean(overrides.newtab);
1366
+ if (declared) return [];
1367
+
1368
+ // MENTIONING THE NEW TAB PAGE IS NOT OVERRIDING IT. This rule used to fire
1369
+ // on the URL appearing anywhere in the code, and against a corpus of 27
1370
+ // real packages that produced FOUR findings and ZERO true positives - a
1371
+ // rule with no demonstrated precision, at the top severity.
1372
+ //
1373
+ // The two benign shapes it could not tell from a hijack:
1374
+ //
1375
+ // 1. NAVIGATING TO the page. xifangczy/cat-catch (21k stars):
1376
+ // if (tabs.length === 1) {
1377
+ // await chrome.tabs.create({ url: 'chrome://newtab' });
1378
+ // which is the documented way to close your last tab without closing
1379
+ // the window.
1380
+ //
1381
+ // 2. RECOGNISING the page in order to LEAVE IT ALONE, which is the more
1382
+ // common of the two and the one I missed on the first pass at this.
1383
+ // obsidianmd/obsidian-clipper (19k):
1384
+ // export function isBlankPage(url: string): boolean {
1385
+ // return url === 'about:blank' || url === 'chrome://newtab/' ...
1386
+ // used by isNormalPageUrl() to decide where a content script MAY be
1387
+ // injected. Chrome forbids content scripts on chrome:// pages, so
1388
+ // every careful extension has a guard like this - which means the old
1389
+ // rule fired hardest on the extensions being most careful.
1390
+ // extension-js and scriptscat/scriptcat were the same shape.
1391
+ //
1392
+ // So the question is not whether the URL appears. It is whether the code
1393
+ // REDIRECTS AWAY from it: the violation is replacing what the user gets
1394
+ // when they open a new tab. That needs two things near each other - a
1395
+ // reference to the page and a navigation - and neither alone means
1396
+ // anything. The old rule asked for one of them.
1397
+ //
1398
+ // Benign DESTINATIONS are blanked first (create/open/update TO the page,
1399
+ // all of which merely visit it), then a reference must sit within 200
1400
+ // characters of a real tabs.update() call. The proximity window is what
1401
+ // keeps a predicate in one function from pairing with a navigation in an
1402
+ // unrelated one further down the file.
1403
+ //
1404
+ // THIS IS A NARROWING, SO THE RISK IS A MISS. Two deliberate limits, named
1405
+ // so a later reader does not mistake them for oversights: an aliased or
1406
+ // destructured tabs API is not matched (`chrome.` or `browser.` is
1407
+ // required, which is also what keeps a test file's mockTabsUpdate out),
1408
+ // and a hijack that redirects by some route other than tabs.update is not
1409
+ // matched. Both are places to widen if a real case ever turns up; neither
1410
+ // is worth failing 4 innocent packages to cover today.
1411
+ const NTP = String.raw`(?:chrome|edge):\/\/newtab|about:newtab`;
1412
+ const visitsNtp = new RegExp(
1413
+ String.raw`(?:(?:chrome|browser)\.)?(?:tabs|windows)\.(?:create|update)\s*\([^;]{0,120}?url\s*:\s*["'\`]\s*(?:${NTP})[^"'\`]*["'\`]` +
1414
+ "|" +
1415
+ String.raw`window\.open\s*\(\s*["'\`]\s*(?:${NTP})[^"'\`]*["'\`]`, "gi");
1416
+ const view = codeView(files).map((f) => {
1417
+ if (!isCode(f)) return f;
1418
+ const text = blank(f.text, visitsNtp);
1419
+ return { ...f, text, lines: text.split("\n") };
1420
+ });
1421
+ const redirect = String.raw`(?:chrome|browser)\.tabs\.update\s*\(`;
1422
+ const hits = grepAcross(view, new RegExp(
1423
+ String.raw`(?:${NTP})[\s\S]{0,200}?${redirect}` + "|" + String.raw`${redirect}[\s\S]{0,200}?(?:${NTP})`,
1424
+ "gi"), isCode);
1425
+ if (!hits.length) return [];
1426
+ return [finding({
1427
+ severity: "fail",
1428
+ title: "The code touches the New Tab Page without declaring the official override",
1429
+ detail:
1430
+ "Changing the New Tab Page or the omnibox by any route other than the documented Override API is the " +
1431
+ "named trigger. Declare chrome_url_overrides.newtab (or chrome_settings_overrides) instead.",
1432
+ evidence: hits,
1433
+ })];
1434
+ },
1435
+ },
1436
+
1437
+ // --- the four 1 August 2026 changes ---------------------------------------
1438
+
1439
+ {
1440
+ id: "disclosure-2026",
1441
+ category: "udp-prominent-disclosure",
1442
+ change: "Disclosure Requirements Policy",
1443
+ run({ manifest }) {
1444
+ if (!manifest) return [];
1445
+ const declared = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])];
1446
+ const dataPerms = declared.filter((p) => DATA_PERMISSIONS.includes(p));
1447
+ const broadHosts = [...new Set(declaredHosts(manifest).filter((h) => BROAD_HOSTS.includes(h)))];
1448
+ const broad = broadHosts.length > 0;
1449
+ if (!dataPerms.length && !broad) return [];
1450
+ return [finding({
1451
+ severity: "warn",
1452
+ title: `User data is in scope (${[...dataPerms, broad ? "broad host access" : null].filter(Boolean).join(", ")}), so disclosure must happen BEFORE installation`,
1453
+ detail:
1454
+ "This changed on 1 August 2026 and the change is easy to miss. The old rule only bit when the data was " +
1455
+ "NOT closely related to the extension's single purpose. The live policy now covers ANY user data. " +
1456
+ "The disclosure lives in your store listing, which this tool cannot see, so verify it yourself. " +
1457
+ "READ THE WARNING BELOW ABOUT GOOGLE'S OWN TROUBLESHOOTING PAGE.",
1458
+ evidence: [
1459
+ ...dataPerms.map((p) => ({ file: "manifest.json", line: 1, text: `"${p}"` })),
1460
+ // Named so the developer can find it: broad access reaches this rule
1461
+ // from three different manifest keys and the fix is in whichever one
1462
+ // they actually wrote.
1463
+ ...broadHosts.map((h) => ({ file: "manifest.json", line: 1, text: `"${h}"` })),
1464
+ ],
1465
+ })];
1466
+ },
1467
+ },
1468
+
1469
+ {
1470
+ id: "limited-use-2026",
1471
+ category: "udp-other-requirements",
1472
+ change: "Limited Use Policy",
1473
+ run({ manifest }) {
1474
+ if (!manifest) return [];
1475
+ // optional_permissions COUNT, and leaving them out was drift rather than a
1476
+ // decision: disclosure-2026 twenty lines up reads both keys, and so does
1477
+ // unused-permissions, so one extension got told its history access was in
1478
+ // scope for disclosure and NOT in scope for limited use. An optional
1479
+ // permission collects exactly the same data once the user grants it; the
1480
+ // difference is when it is asked for, not what it reaches.
1481
+ const declared = [
1482
+ ...(manifest.permissions || []).map((p) => ({ p, optional: false })),
1483
+ ...(manifest.optional_permissions || []).map((p) => ({ p, optional: true })),
1484
+ ];
1485
+ const collecting = declared.filter(({ p }) => ["history", "topSites", "browsingData", "bookmarks", "cookies", "webRequest", "webNavigation"].includes(p));
1486
+ if (!collecting.length) return [];
1487
+ return [finding({
1488
+ severity: "warn",
1489
+ title: `Browsing-activity permissions declared: ${collecting.map(({ p, optional }) => (optional ? `${p} (optional)` : p)).join(", ")}`,
1490
+ detail:
1491
+ "Under the policy as updated on 1 August 2026, data collected must be necessary for the disclosed " +
1492
+ "single purpose. Write down, per permission, the visible feature that stops working without it. If you " +
1493
+ "cannot name one, that permission is the finding. Note the live policy DOES allow related operational " +
1494
+ "purposes such as maintaining, securing or measuring performance; the announcement's wording did not " +
1495
+ "convey that, so do not over-correct.",
1496
+ evidence: collecting.map(({ p, optional }) => ({ file: "manifest.json", line: 1, text: `"${p}"${optional ? " in optional_permissions" : ""}` })),
1497
+ })];
1498
+ },
1499
+ },
1500
+
1501
+ {
1502
+ id: "prediction-markets-2026",
1503
+ category: "gambling",
1504
+ change: "Regulated Goods and Services",
1505
+ run({ manifest, files }) {
1506
+ const hits = grep(files, /prediction market|polymarket|kalshi|sportsbook|betting odds|parlay|real money gambling/i, (f) => isCode(f) || isMarkup(f));
1507
+ const desc = (manifest?.description || "") + " " + (manifest?.name || "");
1508
+ const inDesc = /prediction market|betting|gambling|casino|sportsbook/i.test(desc);
1509
+ if (!hits.length && !inDesc) return [];
1510
+ const evidence = hits.slice(0, 12);
1511
+ if (inDesc) evidence.unshift({ file: "manifest.json", line: 1, text: desc.trim().slice(0, 160) });
1512
+ return [finding({
1513
+ severity: "warn",
1514
+ title: "Prediction market or gambling terms appear in the package",
1515
+ detail:
1516
+ "Prediction markets became explicitly prohibited on 1 August 2026. A mention is not a violation, which " +
1517
+ "is why this warns: the policy bites on facilitating real money transactions. There is also a carve-out " +
1518
+ "the announcement left out entirely, quoted below, for simulated markets with no real money, provided " +
1519
+ "you clearly say no real money is involved.",
1520
+ evidence,
1521
+ })];
1522
+ },
1523
+ },
1524
+
1525
+ {
1526
+ id: "ai-guardrail-2026",
1527
+ category: null,
1528
+ change: "Malicious and Prohibited Products Policy",
1529
+ run({ files, manifest }) {
1530
+ const hits = grep(files, /jailbreak|bypass (the )?(content )?(filter|guardrail|safety)|uncensored (gpt|llm|model)|remove (the )?safety (filter|guardrail)/i, (f) => isCode(f) || isMarkup(f));
1531
+ const desc = (manifest?.description || "") + " " + (manifest?.name || "");
1532
+ const inDesc = /jailbreak|uncensored|bypass.{0,12}(filter|guardrail)/i.test(desc);
1533
+ if (!hits.length && !inDesc) return [];
1534
+ const evidence = hits.slice(0, 12);
1535
+ if (inDesc) evidence.unshift({ file: "manifest.json", line: 1, text: desc.trim().slice(0, 160) });
1536
+ return [finding({
1537
+ severity: "info",
1538
+ title: "Language suggesting circumvention of an AI service's safety guardrails",
1539
+ detail:
1540
+ "Announced on 1 July 2026 as a new prohibition, effective 1 August. IT IS INFO RATHER THAN A FAILURE " +
1541
+ "FOR A SPECIFIC REASON: as of the dataset date this clause exists ONLY in the announcement blog post. " +
1542
+ "The words guardrail and AI-powered appear on no Chrome Web Store policy page, the live Malicious and " +
1543
+ "Prohibited Products page still carries a 2022 last-updated date, and there is no notification ID for " +
1544
+ "it. So there is nothing enforceable to quote at you, and equally nothing to rely on.",
1545
+ evidence,
1546
+ })];
1547
+ },
1548
+ },
1549
+ ];