webstore-lint 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +356 -0
- package/action/report.mjs +222 -0
- package/action.yml +84 -0
- package/bin/webstore-lint.mjs +304 -0
- package/data/policy.json +652 -0
- package/package.json +48 -0
- package/src/audit.mjs +544 -0
- package/src/lint.mjs +125 -0
- package/src/privacy.mjs +218 -0
- package/src/rules.mjs +1549 -0
- package/src/scan.mjs +705 -0
package/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
|
+
];
|