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/scan.mjs
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
// Read an unpacked extension directory into memory, once, so every rule works
|
|
2
|
+
// from the same picture instead of walking the tree again.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS DELIBERATELY NOT READ: node_modules, .git, and anything over 2 MB.
|
|
5
|
+
// A rule that greps a 40 MB bundled vendor file produces a "finding" in code the
|
|
6
|
+
// developer did not write and cannot fix, which trains people to ignore output.
|
|
7
|
+
// Skipped files are RECORDED and reported, because a silent skip is how a linter
|
|
8
|
+
// says "clean" about a directory it never looked at.
|
|
9
|
+
|
|
10
|
+
import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
11
|
+
import { join, relative, extname } from "node:path";
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13
|
+
|
|
14
|
+
// EVERY JSON FILE IN AN EXTENSION IS READ THROUGH THIS, and it exists for one
|
|
15
|
+
// byte sequence. `readFileSync(f, "utf8")` keeps a leading UTF-8 BOM (U+FEFF) in
|
|
16
|
+
// the string, and `JSON.parse` rejects it - while Chrome accepts it, because
|
|
17
|
+
// Chromium's JSON reader consumes a BOM before parsing. So a file Chrome loads
|
|
18
|
+
// without complaint reads to us as "not valid JSON".
|
|
19
|
+
//
|
|
20
|
+
// That is not hypothetical and it is not rare enough to ignore. In a corpus of
|
|
21
|
+
// 160 public repos, 4 of 2220 `messages.json` files carry a BOM, and two of them
|
|
22
|
+
// belong to extensions published on the Web Store right now: MarvellousSuspender
|
|
23
|
+
// (the maintained fork of The Great Suspender) and SmartProxy. MarvellousSuspender
|
|
24
|
+
// was reported at FAIL - "a localised manifest field does not resolve" - about a
|
|
25
|
+
// file with 374 perfectly good keys in it.
|
|
26
|
+
//
|
|
27
|
+
// THE DAMAGE IS WORSE THAN THE ONE FINDING. That extension's manifest carries
|
|
28
|
+
// `"name": "__MSG_ext_extension_name__"` with `default_locale: en`, so failing to
|
|
29
|
+
// read the locale file means we never learn its name or description at all, and
|
|
30
|
+
// every rule that reads either one is then reading a placeholder. One unparsed
|
|
31
|
+
// byte at offset 0 silently changes the input of four other rules.
|
|
32
|
+
//
|
|
33
|
+
// The manifest reader gets the same treatment. No manifest.json in the corpus
|
|
34
|
+
// carries a BOM today, so that half is a latent trap rather than a measured one -
|
|
35
|
+
// but it is the same one-line mistake at a far worse site, since an unparseable
|
|
36
|
+
// manifest aborts the whole scan and reports a working extension as unreadable.
|
|
37
|
+
const stripBom = (s) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s);
|
|
38
|
+
const parseJson = (text) => JSON.parse(stripBom(text));
|
|
39
|
+
|
|
40
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".svn", "dist-zip", ".cache"]);
|
|
41
|
+
|
|
42
|
+
// HALF OF ALL EXTENSION PROJECTS HAVE NO MANIFEST TO READ, and telling those
|
|
43
|
+
// developers "no manifest.json in this directory" sends them looking for a file
|
|
44
|
+
// that does not exist anywhere yet. Measured, not guessed: of 160 public repos
|
|
45
|
+
// in research/data/lint-corpus.json, 90 could not be linted as checked in, and
|
|
46
|
+
// of the 84 that were not simply MV2, 45 are a recognisable framework build -
|
|
47
|
+
// wxt 18, plasmo 14, crxjs 8, vite-plugin-web-extension 1, plus 8 on bare
|
|
48
|
+
// webpack or vite. Our README tells a stranger to run the tool on
|
|
49
|
+
// ./my-extension; for these that directory only exists after a build.
|
|
50
|
+
//
|
|
51
|
+
// THE OUTPUT PATHS ARE READ OFF THE CORPUS, not off memory. Counting the paths
|
|
52
|
+
// that appear in .gitignore, README.md and package.json across those repos:
|
|
53
|
+
// `.output/chrome-mv3` 8 (wxt, alongside firefox-mv2/edge-mv3/safari-mv3
|
|
54
|
+
// siblings) and `build/chrome-mv3-prod` 9 against `build/chrome-mv3-dev` 12 -
|
|
55
|
+
// dev is commoner in the corpus but prod is what you submit, so prod is what we
|
|
56
|
+
// name.
|
|
57
|
+
//
|
|
58
|
+
// ORDER MATTERS AND IS NOT ALPHABETICAL: a wxt project also depends on vite, and
|
|
59
|
+
// a plasmo project also looks like a bare node project, so the specific
|
|
60
|
+
// frameworks are tested before the generic bundlers. The generic entries
|
|
61
|
+
// deliberately do NOT name a directory - guessing `dist` for an arbitrary
|
|
62
|
+
// webpack config would be the same misdiagnosis one level down.
|
|
63
|
+
const FRAMEWORKS = [
|
|
64
|
+
{ name: "wxt", files: [/^wxt\.config\.[cm]?[jt]s$/], deps: ["wxt"], build: "npx wxt build", out: ".output/chrome-mv3" },
|
|
65
|
+
{ name: "Plasmo", deps: ["plasmo"], build: "npm run build", out: "build/chrome-mv3-prod" },
|
|
66
|
+
{ name: "CRXJS", deps: ["@crxjs/vite-plugin"], build: "npm run build", out: "dist" },
|
|
67
|
+
{ name: "vite-plugin-web-extension", deps: ["vite-plugin-web-extension", "@samrum/vite-plugin-web-extension"], build: "npm run build", out: "dist" },
|
|
68
|
+
{ name: "webextension-toolbox", deps: ["webextension-toolbox"], build: "npm run build", out: "dist/chrome" },
|
|
69
|
+
{ name: "webpack", deps: ["webpack"], build: "npm run build", out: null },
|
|
70
|
+
{ name: "Vite", deps: ["vite"], build: "npm run build", out: null },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Returns the build hint for a directory that has no manifest.json, or null when
|
|
74
|
+
// nothing recognisable is there. Never throws: an unreadable or invalid
|
|
75
|
+
// package.json means "no hint", because a crash here would replace a usable
|
|
76
|
+
// error message with a stack trace.
|
|
77
|
+
export function buildHint(root, exists = existsSync) {
|
|
78
|
+
let pkg = {};
|
|
79
|
+
try { pkg = parseJson(readFileSync(join(root, "package.json"), "utf8")) || {}; } catch { pkg = {}; }
|
|
80
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
81
|
+
let names = [];
|
|
82
|
+
try { names = readdirSync(root); } catch { names = []; }
|
|
83
|
+
|
|
84
|
+
for (const fw of FRAMEWORKS) {
|
|
85
|
+
const byFile = (fw.files || []).some((rx) => names.some((n) => rx.test(n)));
|
|
86
|
+
const byDep = (fw.deps || []).some((d) => Object.prototype.hasOwnProperty.call(deps, d));
|
|
87
|
+
if (!byFile && !byDep) continue;
|
|
88
|
+
// If the build has already been run, say where it landed rather than telling
|
|
89
|
+
// someone to build again. Only claimed when the manifest is actually there,
|
|
90
|
+
// so this cannot point at a stale empty directory.
|
|
91
|
+
if (fw.out && exists(join(root, fw.out, "manifest.json"))) {
|
|
92
|
+
return `This is a ${fw.name} project and you have already built it. Run: webstore-lint ${fw.out}`;
|
|
93
|
+
}
|
|
94
|
+
return fw.out
|
|
95
|
+
? `This is a ${fw.name} project, so the manifest is generated by the build and does not exist yet. Run "${fw.build}", then: webstore-lint ${fw.out}`
|
|
96
|
+
: `This looks like a ${fw.name} build, so the manifest is generated and does not exist yet. Run "${fw.build}", then point webstore-lint at your build output directory.`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
101
|
+
const TEXT_EXT = new Set([
|
|
102
|
+
".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx", ".json", ".html", ".htm",
|
|
103
|
+
".css", ".txt", ".md", ".webmanifest",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
export function scan(root) {
|
|
107
|
+
const files = [];
|
|
108
|
+
const skipped = [];
|
|
109
|
+
// Files too big to hold in memory, kept as a work list rather than only as an
|
|
110
|
+
// apology. `skipped` stays exactly as it was, because every other rule really
|
|
111
|
+
// did not read these and its honesty warning has to keep counting them; a rule
|
|
112
|
+
// that DOES stream one subtracts it from that list itself.
|
|
113
|
+
const oversized = [];
|
|
114
|
+
|
|
115
|
+
const walk = (dir) => {
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
skipped.push({ path: relative(root, dir) || ".", why: `unreadable: ${e.code || e.message}` });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
const full = join(dir, e.name);
|
|
125
|
+
const rel = relative(root, full);
|
|
126
|
+
if (e.isDirectory()) {
|
|
127
|
+
if (SKIP_DIRS.has(e.name)) { skipped.push({ path: rel, why: "not part of a submitted package" }); continue; }
|
|
128
|
+
walk(full);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!e.isFile()) continue;
|
|
132
|
+
const ext = extname(e.name).toLowerCase();
|
|
133
|
+
if (!TEXT_EXT.has(ext)) continue; // images, fonts, binaries: nothing to read
|
|
134
|
+
let st;
|
|
135
|
+
try { st = statSync(full); } catch { continue; }
|
|
136
|
+
if (st.size > MAX_BYTES) {
|
|
137
|
+
skipped.push({ path: rel, why: `${(st.size / 1048576).toFixed(1)} MB, over the 2 MB read limit` });
|
|
138
|
+
oversized.push({ path: rel, abs: full, ext, size: st.size });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let text;
|
|
142
|
+
try { text = readFileSync(full, "utf8"); } catch (err) {
|
|
143
|
+
skipped.push({ path: rel, why: `unreadable: ${err.code || err.message}` });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
files.push({ path: rel, ext, text, lines: text.split("\n") });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
walk(root);
|
|
151
|
+
|
|
152
|
+
const manifestPath = join(root, "manifest.json");
|
|
153
|
+
let manifest = null;
|
|
154
|
+
let manifestError = null;
|
|
155
|
+
let manifestHint = null;
|
|
156
|
+
if (!existsSync(manifestPath)) {
|
|
157
|
+
// NAME THE ACTUAL PROBLEM. "no manifest.json in this directory" was printed
|
|
158
|
+
// for a path that is not a directory and for a path that does not exist at
|
|
159
|
+
// all, which sends someone to look for a file in a folder that was never
|
|
160
|
+
// there - the same shape of misdiagnosis as measuring the length of an
|
|
161
|
+
// unresolved __MSG_ placeholder. Pointing at manifest.json itself is the
|
|
162
|
+
// commonest of these and the message now says so outright.
|
|
163
|
+
let st = null;
|
|
164
|
+
try { st = statSync(root); } catch { /* below */ }
|
|
165
|
+
manifestError = !st
|
|
166
|
+
? `there is nothing at ${root}`
|
|
167
|
+
: st.isDirectory()
|
|
168
|
+
? "no manifest.json in this directory"
|
|
169
|
+
: /(^|[\\/])manifest\.json$/i.test(root)
|
|
170
|
+
? `${root} is the manifest itself. Point webstore-lint at the directory that contains it.`
|
|
171
|
+
: `${root} is a file, not an unpacked extension directory`;
|
|
172
|
+
// Only for a real directory that simply has no manifest. A path that does
|
|
173
|
+
// not exist, or a file, is a different mistake and a build hint there would
|
|
174
|
+
// be answering a question nobody asked.
|
|
175
|
+
//
|
|
176
|
+
// THIS GUARD IS BELT-AND-BRACES AND THE TESTS DO NOT PROVE IT LOAD-BEARING:
|
|
177
|
+
// removing it fails nothing, because buildHint already returns null for both
|
|
178
|
+
// of those paths (readdirSync and readFileSync both throw ENOTDIR/ENOENT and
|
|
179
|
+
// are caught). That was found by mutating it, not by reading it, and it is
|
|
180
|
+
// recorded here so a later reader does not mistake the passing controls for
|
|
181
|
+
// evidence about this line. The real protection is buildHint's own
|
|
182
|
+
// behaviour, which IS asserted directly. Kept anyway: it costs one statSync
|
|
183
|
+
// field already in hand, and it keeps the intent legible if buildHint is
|
|
184
|
+
// ever made more eager.
|
|
185
|
+
if (st && st.isDirectory()) manifestHint = buildHint(root);
|
|
186
|
+
} else {
|
|
187
|
+
try {
|
|
188
|
+
manifest = parseJson(readFileSync(manifestPath, "utf8"));
|
|
189
|
+
} catch (e) {
|
|
190
|
+
manifestError = `manifest.json is not valid JSON: ${e.message}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const { manifest: localized, unresolved } = resolveI18n(manifest, files, skipped);
|
|
195
|
+
|
|
196
|
+
return { root, files, skipped, oversized, manifest: localized, manifestRaw: manifest, i18nUnresolved: unresolved, manifestError, manifestHint };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Chrome's own localisation, resolved before any rule reads the manifest.
|
|
200
|
+
//
|
|
201
|
+
// WHY THIS IS NOT COSMETIC. A localised extension does not put its description in
|
|
202
|
+
// manifest.json at all: it writes "__MSG_extDescription__" there and the real
|
|
203
|
+
// sentence lives in _locales/<default_locale>/messages.json. Three rules read
|
|
204
|
+
// that text - keyword-stuffing, prediction-markets-2026, ai-guardrail-2026 - and
|
|
205
|
+
// listing-metadata measures its length. Reading the placeholder literally meant
|
|
206
|
+
// TWO extensions with the identical description got opposite answers depending on
|
|
207
|
+
// whether they were localised, and the localised one got the wrong answer BOTH
|
|
208
|
+
// ways: a bogus "the description is 22 characters" warning, and total silence
|
|
209
|
+
// about a description reading "prediction market ... sportsbook betting". The
|
|
210
|
+
// silent direction is the dangerous one, because the finding it dropped is the
|
|
211
|
+
// one about a policy that starts being enforced on 1 August 2026.
|
|
212
|
+
//
|
|
213
|
+
// FAIL-SAFE DIRECTION: an unresolvable placeholder is left EXACTLY as written.
|
|
214
|
+
// This function never invents text. What it cannot resolve it reports, and the
|
|
215
|
+
// caller decides - a substituted guess would put words in a developer's listing
|
|
216
|
+
// that they never wrote.
|
|
217
|
+
//
|
|
218
|
+
// Only `name` and `description` are substituted, because those are the fields the
|
|
219
|
+
// rules read. Chrome allows __MSG_ in several other manifest fields; adding one
|
|
220
|
+
// here means checking that no rule reads it structurally first.
|
|
221
|
+
const I18N_FIELDS = ["name", "description"];
|
|
222
|
+
|
|
223
|
+
export function resolveI18n(manifest, files, skipped = []) {
|
|
224
|
+
const unresolved = [];
|
|
225
|
+
if (!manifest) return { manifest, unresolved };
|
|
226
|
+
|
|
227
|
+
const needs = I18N_FIELDS.filter((f) => typeof manifest[f] === "string" && /__MSG_[A-Za-z0-9_@]+__/.test(manifest[f]));
|
|
228
|
+
if (!needs.length) return { manifest, unresolved };
|
|
229
|
+
|
|
230
|
+
const locale = typeof manifest.default_locale === "string" ? manifest.default_locale : null;
|
|
231
|
+
const wanted = locale ? `_locales/${locale}/messages.json` : null;
|
|
232
|
+
const file = wanted ? files.find((f) => f.path.split(/[\\/]/).join("/") === wanted) : null;
|
|
233
|
+
// A locale file the scanner refused to read (over the size limit, unreadable)
|
|
234
|
+
// is NOT evidence that a message is missing. Staying quiet there is the same
|
|
235
|
+
// fail-safe direction as everything else in this file.
|
|
236
|
+
const wasSkipped = wanted ? skipped.some((s) => s.path.split(/[\\/]/).join("/") === wanted) : false;
|
|
237
|
+
|
|
238
|
+
let messages = null;
|
|
239
|
+
if (file) {
|
|
240
|
+
try {
|
|
241
|
+
const parsed = parseJson(file.text);
|
|
242
|
+
// Chrome treats message names as case-insensitive, so the lookup is too.
|
|
243
|
+
messages = new Map(Object.entries(parsed).map(([k, v]) => [k.toLowerCase(), v]));
|
|
244
|
+
} catch (e) {
|
|
245
|
+
unresolved.push({ field: null, placeholder: null, why: `${wanted} is not valid JSON: ${e.message}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const out = { ...manifest };
|
|
250
|
+
for (const field of needs) {
|
|
251
|
+
out[field] = manifest[field].replace(/__MSG_([A-Za-z0-9_@]+)__/g, (whole, key) => {
|
|
252
|
+
const entry = messages?.get(key.toLowerCase());
|
|
253
|
+
const text = entry && typeof entry.message === "string" ? entry.message : null;
|
|
254
|
+
if (text !== null) return text;
|
|
255
|
+
if (wasSkipped) return whole; // reported by the scanner already; do not double-accuse
|
|
256
|
+
unresolved.push({
|
|
257
|
+
field,
|
|
258
|
+
placeholder: whole,
|
|
259
|
+
why: !locale
|
|
260
|
+
? `manifest.json uses ${whole} but declares no default_locale`
|
|
261
|
+
: !file
|
|
262
|
+
? `manifest.json uses ${whole} but the package has no ${wanted}`
|
|
263
|
+
: `${wanted} has no message named ${key}`,
|
|
264
|
+
});
|
|
265
|
+
return whole;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return { manifest: out, unresolved };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Find every line matching a pattern, with its file and 1-indexed line number.
|
|
272
|
+
// Evidence is what separates a finding from an opinion, so every rule uses this
|
|
273
|
+
// rather than testing whole-file text and reporting the file alone.
|
|
274
|
+
//
|
|
275
|
+
// ONE MATCH PER LINE, AND ON MINIFIED CODE A LINE IS THE WHOLE FILE. That is
|
|
276
|
+
// fine for evidence - one site is enough to make a developer look - but it means
|
|
277
|
+
// WHICHEVER SITE COMES FIRST IN THE FILE IS THE ONLY SITE ANYONE EVER SEES. If
|
|
278
|
+
// that first site is something the rule would rather not report, the effect is
|
|
279
|
+
// not a tidier finding: it is a REAL site, later in the same line, that the tool
|
|
280
|
+
// silently never mentions. Measured on shipped packages (T-0414): screenity's
|
|
281
|
+
// `cloudrecorder.bundle.js` and `contentScript.bundle.js` each begin with
|
|
282
|
+
// webpack's `new Function("return this")` globalThis shim, and each hides a
|
|
283
|
+
// `new Function(scriptText)(window)` further along the same 460 KB line.
|
|
284
|
+
//
|
|
285
|
+
// So `accept` does NOT filter the returned hits. It is consulted per MATCH, and
|
|
286
|
+
// a rejected match makes the scan CONTINUE ALONG THE SAME LINE to the next one.
|
|
287
|
+
// Rejecting a site can therefore only ever reveal another site or leave the line
|
|
288
|
+
// unreported - never mask one. Filtering afterwards would have dropped both
|
|
289
|
+
// screenity files entirely and called that an improvement.
|
|
290
|
+
// THE EVIDENCE MUST CONTAIN THE MATCH, and until s106 nothing required it to.
|
|
291
|
+
// `text` was the first 160 characters of the line, which is correct for ordinary
|
|
292
|
+
// code and actively misleading for the one case that matters most.
|
|
293
|
+
//
|
|
294
|
+
// Found on a real repo in the corpus (s106). A malicious `postcss.config.mjs`
|
|
295
|
+
// ended with the legitimate line `export default config;`, then **507 spaces**,
|
|
296
|
+
// then 8.6 KB of obfuscated loader that resolves a C2 address out of an Ethereum
|
|
297
|
+
// transaction and `eval`s what it downloads. The rule fired correctly at FAIL.
|
|
298
|
+
// The evidence we printed for it was:
|
|
299
|
+
//
|
|
300
|
+
// postcss.config.mjs:9 export default config;
|
|
301
|
+
//
|
|
302
|
+
// - innocent code and whitespace, because the payload starts 529 characters in.
|
|
303
|
+
// A developer reading that dismisses the most serious finding this tool can
|
|
304
|
+
// produce as an obvious false positive. Padding to push code off the right edge
|
|
305
|
+
// is a deliberate hiding technique, so the excerpt has to follow the match.
|
|
306
|
+
//
|
|
307
|
+
// Ordinary lines are byte-identical to before: when the match already falls
|
|
308
|
+
// inside the first EXCERPT characters this returns exactly what it used to, so
|
|
309
|
+
// the change cannot quietly rewrite the evidence on every other finding.
|
|
310
|
+
const EXCERPT = 160;
|
|
311
|
+
export function excerptAround(line, index = 0, matchLen = 0) {
|
|
312
|
+
const lead = line.length - line.trimStart().length;
|
|
313
|
+
const trimmed = line.trim();
|
|
314
|
+
const at = Math.max(0, index - lead);
|
|
315
|
+
if (at + matchLen <= EXCERPT) return trimmed.slice(0, EXCERPT);
|
|
316
|
+
// Keep a little context before the match so the reader can see what it sits in.
|
|
317
|
+
const start = Math.max(0, at - 40);
|
|
318
|
+
return "..." + trimmed.slice(start, start + EXCERPT - 3);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function grep(files, re, filter = () => true, accept = null) {
|
|
322
|
+
const hits = [];
|
|
323
|
+
const push = (f, i, m) =>
|
|
324
|
+
hits.push({
|
|
325
|
+
file: f.path, line: i + 1, match: m[0].slice(0, 120),
|
|
326
|
+
// `col` is carried so a caller that substitutes a DIFFERENT rendering of
|
|
327
|
+
// the same line (rules.mjs swaps the comment-blanked line for the original
|
|
328
|
+
// one) can re-excerpt around the same site instead of falling back to the
|
|
329
|
+
// first 160 characters. Without it that substitution silently undid this.
|
|
330
|
+
col: m.index,
|
|
331
|
+
text: excerptAround(f.lines[i], m.index, m[0].length),
|
|
332
|
+
});
|
|
333
|
+
for (const f of files) {
|
|
334
|
+
if (!filter(f)) continue;
|
|
335
|
+
for (let i = 0; i < f.lines.length; i++) {
|
|
336
|
+
if (!accept) {
|
|
337
|
+
const rx = new RegExp(re.source, re.flags.replace("g", ""));
|
|
338
|
+
const m = rx.exec(f.lines[i]);
|
|
339
|
+
if (m) push(f, i, m);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// `g` is required to advance lastIndex; the caller's regex may not have it.
|
|
343
|
+
const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
344
|
+
let m;
|
|
345
|
+
while ((m = rx.exec(f.lines[i])) !== null) {
|
|
346
|
+
if (m[0].length === 0) { rx.lastIndex++; continue; } // a zero-width match never advances
|
|
347
|
+
if (accept(m, f.lines[i])) { push(f, i, m); break; }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return hits;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The same search, but over the whole file instead of line by line, still
|
|
355
|
+
// reporting the 1-indexed line the match STARTS on so the evidence is unchanged.
|
|
356
|
+
//
|
|
357
|
+
// WHY BOTH EXIST. grep() above is line-based, and for most rules that is right:
|
|
358
|
+
// the pattern and the evidence are one line. But an HTML attribute is not
|
|
359
|
+
// line-shaped. A long <script src="..."> wrapped across lines - which is exactly
|
|
360
|
+
// what a formatter does to it - was invisible to grep(), so two byte-identical
|
|
361
|
+
// pages differing ONLY in where the line breaks fell got different answers, and
|
|
362
|
+
// the silent one was the formatted one. That was in remote-code, the highest
|
|
363
|
+
// severity rule in the tool and the first trigger Google names for MV3.
|
|
364
|
+
//
|
|
365
|
+
// Use this for any pattern that can legally span a newline. Do NOT use it for
|
|
366
|
+
// patterns anchored to line structure - matching across lines is the whole
|
|
367
|
+
// point, and for a line-anchored rule that is a false positive waiting to happen.
|
|
368
|
+
export function grepAcross(files, re, filter = () => true) {
|
|
369
|
+
const hits = [];
|
|
370
|
+
for (const f of files) {
|
|
371
|
+
if (!filter(f)) continue;
|
|
372
|
+
const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
373
|
+
let m;
|
|
374
|
+
while ((m = rx.exec(f.text)) !== null) {
|
|
375
|
+
// Count the newlines before the match rather than searching the lines
|
|
376
|
+
// array, so a match that spans lines is attributed to the line it opens on.
|
|
377
|
+
const before = f.text.slice(0, m.index);
|
|
378
|
+
const line = before.split("\n").length;
|
|
379
|
+
// Offset of the match WITHIN its opening line, so the excerpt can follow a
|
|
380
|
+
// match that sits far to the right of a padded line - same reason as grep().
|
|
381
|
+
const col = m.index - (before.lastIndexOf("\n") + 1);
|
|
382
|
+
hits.push({
|
|
383
|
+
file: f.path,
|
|
384
|
+
line,
|
|
385
|
+
match: m[0].replace(/\s+/g, " ").slice(0, 120),
|
|
386
|
+
col,
|
|
387
|
+
text: excerptAround(f.lines[line - 1] || "", col, m[0].length),
|
|
388
|
+
});
|
|
389
|
+
// A zero-length match would spin forever.
|
|
390
|
+
if (m.index === rx.lastIndex) rx.lastIndex++;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return hits;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const isCode = (f) => [".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx"].includes(f.ext);
|
|
397
|
+
export const isMarkup = (f) => [".html", ".htm"].includes(f.ext);
|
|
398
|
+
|
|
399
|
+
// Blank out comments, keeping every other byte where it was.
|
|
400
|
+
//
|
|
401
|
+
// WHY THIS LIVES HERE rather than in the one rule that first needed it: a
|
|
402
|
+
// commented-out call used to count as a call site, so an extension that DELETED
|
|
403
|
+
// its chrome.bookmarks code and left "// we used to call chrome.bookmarks.getTree
|
|
404
|
+
// here" behind was reported as USING bookmarks. That is Google's Purple Potassium
|
|
405
|
+
// case exactly. Two separate places ask that question - audit.mjs for the ledger
|
|
406
|
+
// and the unused-permissions rule for the verdict - and when they disagreed the
|
|
407
|
+
// tool contradicted itself. The drift between two copies has already been a bug
|
|
408
|
+
// here once (see declaredHosts), so this is shared.
|
|
409
|
+
//
|
|
410
|
+
// Comment bytes become SPACES rather than being removed, so offsets, line counts
|
|
411
|
+
// and therefore every reported line number are unchanged.
|
|
412
|
+
//
|
|
413
|
+
// THE FAIL-SAFE DIRECTION IS DELIBERATE. Blanking real code would invent an
|
|
414
|
+
// "unused" verdict, and that advice deletes a permission the extension needs -
|
|
415
|
+
// the expensive direction. So blanking happens ONLY from a comment opener found
|
|
416
|
+
// in normal state: strings and template literals are tracked and skipped, and an
|
|
417
|
+
// ambiguous slash is resolved toward "not a comment". A missed comment leaves a
|
|
418
|
+
// permission reading "used", which is a miss rather than a false accusation.
|
|
419
|
+
// THERE IS ONE IMPLEMENTATION AND IT IS THE RESUMABLE ONE, on purpose. The
|
|
420
|
+
// streaming scan (grepLarge, below) has to blank comments in a file it never
|
|
421
|
+
// holds whole, and the obvious way to get that is a second copy of this state
|
|
422
|
+
// machine that walks a chunk. The drift between two copies of a shared rule has
|
|
423
|
+
// already been a bug in this file once (see declaredHosts in rules.mjs), and
|
|
424
|
+
// this one decides whether a FAIL-severity site is visible - so instead
|
|
425
|
+
// stripComments() is a single call to the chunk stepper with the whole file as
|
|
426
|
+
// one chunk, and test/lint.test.mjs asserts the two paths AGREE byte for byte
|
|
427
|
+
// on real bundles at several chunk sizes.
|
|
428
|
+
export const initialCommentState = () => ({ mode: "code", quote: '"', cls: false, prev: "" });
|
|
429
|
+
|
|
430
|
+
// Blank the comments in ONE chunk, carrying the parse state across the seam.
|
|
431
|
+
//
|
|
432
|
+
// `atEof` false means another chunk is coming, and then this NEVER consumes the
|
|
433
|
+
// final character of the chunk: every decision here needs at most one character
|
|
434
|
+
// of lookahead (`//` against `/*`, `*/` closing a block, a backslash escaping
|
|
435
|
+
// the next byte), and a seam through the middle of any of those pairs decides it
|
|
436
|
+
// wrong. The unconsumed tail comes back as `held` and the caller prepends it to
|
|
437
|
+
// the next chunk. Holding one character uniformly is cheaper to reason about
|
|
438
|
+
// than three special cases, and it is why there is no `pendingSlash` in the
|
|
439
|
+
// state object.
|
|
440
|
+
//
|
|
441
|
+
// Returns { out, held, state }: `out` is the blanked text for the part actually
|
|
442
|
+
// consumed, so out.length + held.length === text.length and every byte offset in
|
|
443
|
+
// `out` is still the offset it had in the file.
|
|
444
|
+
export function stripCommentsChunk(text, state = initialCommentState(), { atEof = true } = {}) {
|
|
445
|
+
const n = text.length;
|
|
446
|
+
const out = text.split("");
|
|
447
|
+
let { mode, quote, cls, prev } = state;
|
|
448
|
+
// Nothing past `stop` is consumed, so every inner scan is bounded by it too.
|
|
449
|
+
// Bounding only the outer loop would let a backslash skip step over the held
|
|
450
|
+
// character, which is exactly the seam this is protecting.
|
|
451
|
+
const stop = atEof ? n : Math.max(0, n - 1);
|
|
452
|
+
const blank = (a, b) => {
|
|
453
|
+
for (let k = a; k < b && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
let i = 0;
|
|
457
|
+
while (i < stop) {
|
|
458
|
+
if (mode === "line") {
|
|
459
|
+
let j = i;
|
|
460
|
+
while (j < stop && text[j] !== "\n") j++;
|
|
461
|
+
blank(i, j);
|
|
462
|
+
i = j;
|
|
463
|
+
if (j < stop) mode = "code"; // the newline itself is left for code mode
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (mode === "block") {
|
|
467
|
+
const k = text.indexOf("*/", i);
|
|
468
|
+
if (k !== -1 && k + 2 <= stop) { blank(i, k + 2); i = k + 2; mode = "code"; continue; }
|
|
469
|
+
// Not closed inside this chunk. A trailing `*` has to be HELD, not
|
|
470
|
+
// blanked: blanking it destroys the first half of a `*/` that straddles
|
|
471
|
+
// the seam, and the next chunk then searches for a closer whose asterisk
|
|
472
|
+
// no longer exists - so the block comment swallows the rest of the file.
|
|
473
|
+
// Same failure as the backslash below, and it is why holding one character
|
|
474
|
+
// is not enough on its own.
|
|
475
|
+
let end = stop;
|
|
476
|
+
if (!atEof && end > i && text[end - 1] === "*") end--;
|
|
477
|
+
blank(i, end);
|
|
478
|
+
i = end;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
if (mode === "str" || mode === "tpl") {
|
|
482
|
+
// A single-quoted or double-quoted string bails at a newline; a template
|
|
483
|
+
// literal does not. Both are STEPPED OVER, never blanked - see the header
|
|
484
|
+
// above and T-0416 for why blanking them is refused.
|
|
485
|
+
const ends = mode === "str" ? (ch) => ch === quote || ch === "\n" : (ch) => ch === "`";
|
|
486
|
+
let j = i;
|
|
487
|
+
// A BACKSLASH IS TWO CHARACTERS AND THE SEAM MUST NOT FALL BETWEEN THEM -
|
|
488
|
+
// handing the escaped character to the next call as an ordinary one ends
|
|
489
|
+
// the string early when it is the closing quote (`\"`, which every
|
|
490
|
+
// embedded JSON blob is full of). No guard is needed for it HERE, and that
|
|
491
|
+
// is a measured claim rather than an assumption: the skip may run j past
|
|
492
|
+
// `stop`, and consuming exactly as far as j - rather than to stop - takes
|
|
493
|
+
// the escaped character with it, which is correct. A version WITH an
|
|
494
|
+
// explicit guard was tried and proved an equivalent mutant, agreeing at
|
|
495
|
+
// every chunk size from 1 upward on the seam fixtures and byte for byte
|
|
496
|
+
// over 36 MB of real bundles. It was removed rather than kept as comfort:
|
|
497
|
+
// a branch that cannot change an answer cannot be tested, and a comment
|
|
498
|
+
// claiming it protects something would be false.
|
|
499
|
+
while (j < stop) {
|
|
500
|
+
if (text[j] === "\\") { j += 2; continue; }
|
|
501
|
+
if (ends(text[j])) break;
|
|
502
|
+
j++;
|
|
503
|
+
}
|
|
504
|
+
if (j < stop && ends(text[j])) { prev = mode === "str" ? quote : "`"; i = j + 1; mode = "code"; continue; }
|
|
505
|
+
i = j;
|
|
506
|
+
break; // holding from j: nothing after it can be decided without the next chunk
|
|
507
|
+
|
|
508
|
+
}
|
|
509
|
+
if (mode === "re") {
|
|
510
|
+
let j = i;
|
|
511
|
+
let done = false;
|
|
512
|
+
while (j < stop) {
|
|
513
|
+
const e = text[j];
|
|
514
|
+
if (e === "\\") { j += 2; continue; } // consuming to j, not to stop, carries the escape
|
|
515
|
+
if (e === "\n") break;
|
|
516
|
+
if (e === "[") cls = true;
|
|
517
|
+
else if (e === "]") cls = false;
|
|
518
|
+
else if (e === "/" && !cls) { done = true; break; }
|
|
519
|
+
j++;
|
|
520
|
+
}
|
|
521
|
+
if (done || (j < stop && text[j] === "\n")) { i = j + 1; mode = "code"; prev = "/"; continue; }
|
|
522
|
+
i = j;
|
|
523
|
+
break; // nothing after j can be decided without the next chunk
|
|
524
|
+
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const c = text[i];
|
|
528
|
+
const d = text[i + 1];
|
|
529
|
+
|
|
530
|
+
if (c === "/" && d === "/") { blank(i, i + 2); i += 2; mode = "line"; continue; }
|
|
531
|
+
if (c === "/" && d === "*") { blank(i, i + 2); i += 2; mode = "block"; continue; }
|
|
532
|
+
if (c === '"' || c === "'") { quote = c; i++; mode = "str"; continue; }
|
|
533
|
+
if (c === "`") { i++; mode = "tpl"; continue; }
|
|
534
|
+
// A slash here is either division or a regex literal. Only the regex case
|
|
535
|
+
// needs skipping, and guessing wrong that way merely means a comment inside
|
|
536
|
+
// the span is missed - the safe direction. An unescaped // cannot appear
|
|
537
|
+
// inside a regex literal anyway, because it would have closed it.
|
|
538
|
+
if (c === "/" && (prev === "" || "=(,:[!&|?{};+-*%~^<>return".includes(prev))) {
|
|
539
|
+
i++;
|
|
540
|
+
cls = false;
|
|
541
|
+
mode = "re";
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (!/\s/.test(c)) prev = c;
|
|
545
|
+
i++;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
out: out.slice(0, i).join(""),
|
|
550
|
+
held: text.slice(i),
|
|
551
|
+
state: { mode, quote, cls, prev },
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Blank out comments, keeping every other byte where it was.
|
|
556
|
+
export function stripComments(text) {
|
|
557
|
+
return stripCommentsChunk(text, initialCommentState(), { atEof: true }).out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Search a file that is too large to hold in memory, in overlapping windows.
|
|
561
|
+
//
|
|
562
|
+
// WHY THIS EXISTS. scan() does not read a file over 2 MB, and the remote-code
|
|
563
|
+
// rule then went silent about it - which a developer reads as "nothing there",
|
|
564
|
+
// on the one rule Google enforces first. Measured on 82 shipped release
|
|
565
|
+
// packages: 23 skip a code file for size and THREE of them hide a real site in
|
|
566
|
+
// one, including `new Function('return (' + source + ');')()` in a 4.0 MB
|
|
567
|
+
// content script.
|
|
568
|
+
//
|
|
569
|
+
// RAISING THE LIMIT IS NOT THE FIX and was rejected: page-assist ships an 8.3 MB
|
|
570
|
+
// chunk, so a bigger number only moves the cliff, and it costs that memory on
|
|
571
|
+
// every scan of every package. This reads a fixed window instead, so a 40 MB
|
|
572
|
+
// file costs the same as a 3 MB one.
|
|
573
|
+
//
|
|
574
|
+
// THE OVERLAP IS THE WHOLE CORRECTNESS ARGUMENT. grepAcross deliberately matches
|
|
575
|
+
// across newlines, so a window boundary can fall through the middle of a match.
|
|
576
|
+
// Every window therefore keeps the last OVERLAP characters of the previous one,
|
|
577
|
+
// and hits are deduplicated by their ABSOLUTE offset in the file - not by their
|
|
578
|
+
// offset in the window, which changes every time the window slides. OVERLAP must
|
|
579
|
+
// stay comfortably longer than the longest pattern any caller passes.
|
|
580
|
+
const WINDOW = 1 << 20; // 1 MB read
|
|
581
|
+
const OVERLAP = 8192;
|
|
582
|
+
|
|
583
|
+
// A match must be reported at a line number and with text a developer can
|
|
584
|
+
// recognise. Neither is free here: the file is never whole, and on a minified
|
|
585
|
+
// bundle "the line" is millions of characters. So the line is counted from the
|
|
586
|
+
// newlines actually passed over, and the evidence is a SNIPPET around the match
|
|
587
|
+
// rather than the line - which is what grep() would have produced anyway once it
|
|
588
|
+
// truncated a 460 KB line to 160 characters.
|
|
589
|
+
// The windowing itself, separated from what any one caller does with it, for the
|
|
590
|
+
// same reason stripComments has exactly one implementation: two rules now read
|
|
591
|
+
// these files and a second copy of the slide-and-overlap arithmetic is a bug
|
|
592
|
+
// waiting to be fixed in one place only.
|
|
593
|
+
//
|
|
594
|
+
// Yields { raw, view, base, lineBase } per window. `raw` is the original text,
|
|
595
|
+
// `view` is the same span with comments blanked when `code` is true, and the two
|
|
596
|
+
// are always the same length - stripComments preserves offsets, which is what
|
|
597
|
+
// lets a match found in `view` be quoted from `raw` and counted in lines.
|
|
598
|
+
export function* largeWindows(f, { code = false } = {}) {
|
|
599
|
+
let fd;
|
|
600
|
+
try { fd = openSync(f.abs, "r"); } catch { return; }
|
|
601
|
+
const decoder = new StringDecoder("utf8");
|
|
602
|
+
const buf = Buffer.allocUnsafe(WINDOW);
|
|
603
|
+
let raw = ""; // window of original text
|
|
604
|
+
let view = ""; // same window, comments blanked when code is true
|
|
605
|
+
let base = 0; // absolute offset of raw[0]
|
|
606
|
+
let lineBase = 1; // 1-indexed line number of raw[0]
|
|
607
|
+
let heldRaw = ""; // characters stripCommentsChunk could not decide yet
|
|
608
|
+
let state = initialCommentState();
|
|
609
|
+
try {
|
|
610
|
+
for (;;) {
|
|
611
|
+
const n = readSync(fd, buf, 0, WINDOW, null);
|
|
612
|
+
const atEof = n === 0;
|
|
613
|
+
const chunk = heldRaw + (atEof ? decoder.end() : decoder.write(buf.subarray(0, n)));
|
|
614
|
+
let add = chunk;
|
|
615
|
+
if (code) {
|
|
616
|
+
const r = stripCommentsChunk(chunk, state, { atEof });
|
|
617
|
+
state = r.state;
|
|
618
|
+
heldRaw = r.held;
|
|
619
|
+
add = r.out;
|
|
620
|
+
raw += chunk.slice(0, r.out.length);
|
|
621
|
+
} else {
|
|
622
|
+
heldRaw = "";
|
|
623
|
+
raw += chunk;
|
|
624
|
+
}
|
|
625
|
+
view += add;
|
|
626
|
+
yield { raw, view, base, lineBase };
|
|
627
|
+
if (atEof) break;
|
|
628
|
+
// Slide, keeping the overlap. Everything dropped is accounted for in base
|
|
629
|
+
// and lineBase so absolute offsets and line numbers keep counting.
|
|
630
|
+
if (view.length > OVERLAP) {
|
|
631
|
+
const drop = view.length - OVERLAP;
|
|
632
|
+
lineBase += countNewlines(raw, 0, drop);
|
|
633
|
+
base += drop;
|
|
634
|
+
view = view.slice(drop);
|
|
635
|
+
raw = raw.slice(drop);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
} finally {
|
|
639
|
+
closeSync(fd);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function grepLarge(oversized, re, { filter = () => true, code = false, accept = null, maxPerFile = 25 } = {}) {
|
|
644
|
+
const hits = [];
|
|
645
|
+
for (const f of oversized || []) {
|
|
646
|
+
if (!filter(f)) continue;
|
|
647
|
+
const emitted = new Set();
|
|
648
|
+
let truncated = false;
|
|
649
|
+
|
|
650
|
+
const search = ({ raw, view, base, lineBase }) => {
|
|
651
|
+
const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
652
|
+
let m;
|
|
653
|
+
while ((m = rx.exec(view)) !== null) {
|
|
654
|
+
if (m[0].length === 0) { rx.lastIndex++; continue; }
|
|
655
|
+
const abs = base + m.index;
|
|
656
|
+
if (emitted.has(abs)) continue;
|
|
657
|
+
// `accept` is consulted per MATCH and a rejection CONTINUES along the
|
|
658
|
+
// window, exactly as in grep(): rejecting a site can only ever reveal
|
|
659
|
+
// another one, never mask it.
|
|
660
|
+
if (accept && !accept(m, view)) continue;
|
|
661
|
+
emitted.add(abs);
|
|
662
|
+
if (emitted.size > maxPerFile) { truncated = true; return; }
|
|
663
|
+
const line = lineBase + countNewlines(raw, 0, m.index);
|
|
664
|
+
const from = Math.max(0, m.index - 40);
|
|
665
|
+
hits.push({
|
|
666
|
+
file: f.path,
|
|
667
|
+
line,
|
|
668
|
+
match: m[0].replace(/\s+/g, " ").slice(0, 120),
|
|
669
|
+
text: raw.slice(from, from + 200).replace(/\s+/g, " ").trim().slice(0, 160),
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
for (const w of largeWindows(f, { code })) {
|
|
675
|
+
search(w);
|
|
676
|
+
if (truncated) break;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (truncated) {
|
|
680
|
+
hits.push({
|
|
681
|
+
file: f.path,
|
|
682
|
+
line: 1,
|
|
683
|
+
match: "",
|
|
684
|
+
text: `more than ${maxPerFile} sites in this file; the rest are not listed`,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return hits;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const countNewlines = (s, from, to) => {
|
|
692
|
+
let c = 0;
|
|
693
|
+
for (let i = from; i < to; i++) if (s.charCodeAt(i) === 10) c++;
|
|
694
|
+
return c;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// The same files with comments blanked, computed once. Every call-site question
|
|
698
|
+
// is about code, not prose. Non-code files are passed through untouched.
|
|
699
|
+
export function codeView(files) {
|
|
700
|
+
return files.map((f) => {
|
|
701
|
+
if (!isCode(f)) return f;
|
|
702
|
+
const text = stripComments(f.text);
|
|
703
|
+
return { ...f, text, lines: text.split("\n") };
|
|
704
|
+
});
|
|
705
|
+
}
|