webstore-lint 1.0.25 → 1.0.27
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/package.json +1 -1
- package/src/rules.mjs +106 -3
- package/src/scan.mjs +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webstore-lint",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.27",
|
|
4
4
|
"description": "Check an unpacked Chrome extension against the Chrome Web Store program policies before you submit it. Every finding cites Google's own notification ID and verbatim policy text. Covers the 1 August 2026 policy updates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chrome-extension",
|
package/src/rules.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// is worth less than none. When in doubt a rule warns and says what a human
|
|
19
19
|
// must check.
|
|
20
20
|
|
|
21
|
-
import { grep, grepAcross, grepLarge, largeWindows, isCode, isMarkup, codeView, excerptAround } from "./scan.mjs";
|
|
21
|
+
import { grep, grepAcross, grepLarge, largeWindows, isCode, isMarkup, codeView, excerptAround, htmlCommentRanges, insideHtmlComment, offsetOf } from "./scan.mjs";
|
|
22
22
|
import {
|
|
23
23
|
MANIFEST_EVIDENCE, PERMISSION_API, NO_NAMESPACE_PERMISSIONS,
|
|
24
24
|
namespaceUsed, looksMinified, looksMinifiedLarge, bareImports,
|
|
@@ -441,7 +441,45 @@ export const RULES = [
|
|
|
441
441
|
evidence: website,
|
|
442
442
|
}));
|
|
443
443
|
}
|
|
444
|
-
|
|
444
|
+
// COMMENTED-OUT TAGS ARE THEIR OWN ANSWER, NOT A FAILURE AND NOT SILENCE.
|
|
445
|
+
// A <script src> inside <!-- --> does not load and cannot be rejected for,
|
|
446
|
+
// so failing on it is the wrong-fail case that gets a linter muted. We did
|
|
447
|
+
// exactly that to google/archat, whose options/options.html carries a
|
|
448
|
+
// commented-out Google Analytics block - the single most visible repo in
|
|
449
|
+
// our corpus, reported at the highest severity the tool has.
|
|
450
|
+
//
|
|
451
|
+
// IT GETS ITS OWN LABEL RATHER THAN JOINING THE WEBSITE OR TEST WARNS
|
|
452
|
+
// (T-0620). Those two sentences say "this file probably does not ship";
|
|
453
|
+
// this one says "this file ships and the tag is one edit from live". Three
|
|
454
|
+
// different facts, three different words. Overloading one of the existing
|
|
455
|
+
// ones would tell a developer to check their build output for a file that
|
|
456
|
+
// is already in it.
|
|
457
|
+
//
|
|
458
|
+
// Measured before it was written: 14 of 703 remote <script src> sites
|
|
459
|
+
// across 6,351 HTML files in the corpus cache are inside a comment, in six
|
|
460
|
+
// distinct repos.
|
|
461
|
+
const commentCache = new Map();
|
|
462
|
+
const inComment = (h) => {
|
|
463
|
+
const f = files.find((x) => x.path === h.file);
|
|
464
|
+
if (!f) return false; // cannot tell -> resolve toward FAIL, as elsewhere in this rule
|
|
465
|
+
if (!commentCache.has(f.path)) commentCache.set(f.path, htmlCommentRanges(f.text));
|
|
466
|
+
return insideHtmlComment(commentCache.get(f.path), offsetOf(f, h.line, h.col));
|
|
467
|
+
};
|
|
468
|
+
const commentedOut = remoteScript.filter(inComment);
|
|
469
|
+
const remoteScriptLive = remoteScript.filter((h) => !inComment(h));
|
|
470
|
+
if (commentedOut.length) {
|
|
471
|
+
out.push(finding({
|
|
472
|
+
severity: "warn",
|
|
473
|
+
title: `A <script> tag loading remote code is commented out (${commentedOut.length} site(s))`,
|
|
474
|
+
detail:
|
|
475
|
+
"Every site below sits inside an HTML comment, so it does not load and Google cannot reject you for it "
|
|
476
|
+
+ "as it stands. It is reported because it ships in the package and is one edit from live: if you "
|
|
477
|
+
+ "uncomment it before submitting, it becomes the first trigger Google names for this category. Delete "
|
|
478
|
+
+ "it, or vendor the file into the package before you re-enable it.",
|
|
479
|
+
evidence: commentedOut,
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
split(remoteScriptLive, (shipped, tests) => shipped.length
|
|
445
483
|
? finding({
|
|
446
484
|
severity: "fail",
|
|
447
485
|
title: "A <script> tag loads code from outside the extension package",
|
|
@@ -665,7 +703,72 @@ export const RULES = [
|
|
|
665
703
|
// separately so a 4 MB content script produces the same FAIL as a 4 KB
|
|
666
704
|
// one; the size of the file is the tool's problem, not the developer's.
|
|
667
705
|
.concat(grepLarge(oversized, EVAL_RE, { filter: isCode, code: true, accept: executesAString }));
|
|
668
|
-
|
|
706
|
+
// A DECLARED SANDBOX PAGE IS WHERE CHROME TELLS YOU TO PUT eval, and until
|
|
707
|
+
// s147 this rule FAILed the developers who followed that advice. Found by
|
|
708
|
+
// running the corpus against the new code-search index: vladlavrik/netify
|
|
709
|
+
// declares `"sandbox": {"pages": ["sandbox.html"]}` with a sandbox CSP of
|
|
710
|
+
// `... 'unsafe-inline' 'unsafe-eval'`, and its one eval site is
|
|
711
|
+
// `services/sandbox/executor/SandboxExecutor.ts` - the executor that RUNS
|
|
712
|
+
// in that page. That is the documented, sanctioned escape hatch, and we
|
|
713
|
+
// were reporting it at the top severity.
|
|
714
|
+
//
|
|
715
|
+
// This is the worst direction for a linter to be wrong in, and it is worse
|
|
716
|
+
// than the usual wrong FAIL: it fires hardest on the developer who read
|
|
717
|
+
// the policy and did the compliant thing, which is precisely the developer
|
|
718
|
+
// whose trust the tool needs.
|
|
719
|
+
//
|
|
720
|
+
// A SANDBOX DOES NOT LAUNDER THE WHOLE PACKAGE, so this is a partition and
|
|
721
|
+
// not an exemption. The manifest itself names the files that can never be
|
|
722
|
+
// sandboxed - the service worker and every declared content script - and
|
|
723
|
+
// eval in one of those is a violation whatever else the package declares.
|
|
724
|
+
// Those keep FAILing. Everything else becomes a warn that says plainly
|
|
725
|
+
// that the tool cannot tell which side of the boundary the call sits on,
|
|
726
|
+
// because from the file alone it genuinely cannot.
|
|
727
|
+
//
|
|
728
|
+
// AN EMPTY `sandbox.pages` IS NOT A SANDBOX, and a custom sandbox CSP that
|
|
729
|
+
// REMOVES 'unsafe-eval' is not one either - Chrome's default sandbox CSP
|
|
730
|
+
// grants it, so absence of a custom CSP means granted, but an explicit one
|
|
731
|
+
// that drops the token means the page cannot eval and the finding stands.
|
|
732
|
+
const sandboxPages = (manifest?.sandbox?.pages || []).filter(Boolean);
|
|
733
|
+
const sandboxCsp = manifest?.content_security_policy?.sandbox;
|
|
734
|
+
const sandboxAllowsEval = sandboxPages.length > 0
|
|
735
|
+
&& (typeof sandboxCsp !== "string" || /'unsafe-eval'/.test(sandboxCsp));
|
|
736
|
+
|
|
737
|
+
// Named by the manifest, so this is not a guess about the file's role.
|
|
738
|
+
const norm = (p) => String(p).replace(/^\.?\//, "").toLowerCase();
|
|
739
|
+
const neverSandboxable = new Set();
|
|
740
|
+
if (manifest?.background?.service_worker) neverSandboxable.add(norm(manifest.background.service_worker));
|
|
741
|
+
for (const s of manifest?.background?.scripts || []) neverSandboxable.add(norm(s));
|
|
742
|
+
for (const cs of manifest?.content_scripts || []) {
|
|
743
|
+
for (const j of cs?.js || []) neverSandboxable.add(norm(j));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let evalsForSplit = evals;
|
|
747
|
+
if (sandboxAllowsEval) {
|
|
748
|
+
const hard = evals.filter((h) => neverSandboxable.has(norm(h.file)));
|
|
749
|
+
const soft = evals.filter((h) => !neverSandboxable.has(norm(h.file)));
|
|
750
|
+
evalsForSplit = hard;
|
|
751
|
+
// Emitted whenever there are any, never gated on the FAIL being absent -
|
|
752
|
+
// the same reasoning as the website partition above. A package with one
|
|
753
|
+
// service-worker eval and four sandbox-plausible ones must report all
|
|
754
|
+
// five, or the partition has quietly become a deletion.
|
|
755
|
+
if (soft.length) {
|
|
756
|
+
out.push(finding({
|
|
757
|
+
severity: "warn",
|
|
758
|
+
title: `eval() or new Function(), in a package that declares a sandboxed page (${soft.length} site(s))`,
|
|
759
|
+
detail:
|
|
760
|
+
"Your manifest declares sandbox.pages, and Chrome permits eval inside a sandboxed page - it is the "
|
|
761
|
+
+ "documented way to execute a string in an extension. None of these sites is in your service worker "
|
|
762
|
+
+ "or in a declared content script, so this tool cannot tell from the file alone whether they run "
|
|
763
|
+
+ "inside the sandbox. If they do, they are allowed and you can ignore this. If any of them is "
|
|
764
|
+
+ "reached from an ordinary extension page instead, it is still the violation Google names."
|
|
765
|
+
+ ` Sandboxed page(s) declared: ${sandboxPages.join(", ")}.`,
|
|
766
|
+
evidence: soft,
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
split(evalsForSplit, (shipped, tests) => shipped.length
|
|
669
772
|
? finding({
|
|
670
773
|
severity: "fail",
|
|
671
774
|
title: "eval() or new Function() executes a string as code",
|
package/src/scan.mjs
CHANGED
|
@@ -396,6 +396,52 @@ export function grepAcross(files, re, filter = () => true) {
|
|
|
396
396
|
export const isCode = (f) => [".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx"].includes(f.ext);
|
|
397
397
|
export const isMarkup = (f) => [".html", ".htm"].includes(f.ext);
|
|
398
398
|
|
|
399
|
+
// Where a grepAcross hit sits in the file, from the line and column it reports.
|
|
400
|
+
//
|
|
401
|
+
// grepAcross deliberately returns line+col rather than the raw offset, because
|
|
402
|
+
// that is what evidence carries and an extra field on a hit ends up in the
|
|
403
|
+
// published report. A caller that needs the offset back can ask for it here
|
|
404
|
+
// instead of every caller re-deriving it slightly differently.
|
|
405
|
+
export function offsetOf(file, line, col) {
|
|
406
|
+
let at = 0;
|
|
407
|
+
for (let i = 0; i < line - 1; i++) at += (file.lines[i]?.length ?? 0) + 1; // +1 for the newline
|
|
408
|
+
return at + col;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The byte ranges of every HTML comment in a document.
|
|
412
|
+
//
|
|
413
|
+
// WHY A LINTER NEEDS THIS AT ALL. A commented-out <script src="https://..."> is
|
|
414
|
+
// not remote code. It does not load, it does not run, and Google cannot reject
|
|
415
|
+
// an extension for it. Reporting one at FAIL - the highest severity this tool
|
|
416
|
+
// has - is the wrong-fail case that gets a linter muted, and it happened on the
|
|
417
|
+
// most visible repo in our own corpus: google/archat carries a commented-out
|
|
418
|
+
// Google Analytics block in options/options.html and we called it a policy
|
|
419
|
+
// violation. Measured across 6,351 HTML files in the 1,079-repo clone cache,
|
|
420
|
+
// 14 of 703 remote <script src> sites (2.0%) are inside a comment.
|
|
421
|
+
//
|
|
422
|
+
// AN UNTERMINATED `<!--` IS TREATED AS NOT-A-COMMENT, which is deliberate and is
|
|
423
|
+
// the one place this disagrees with a browser (an unclosed comment really does
|
|
424
|
+
// swallow the rest of the document). The rule that uses this resolves every
|
|
425
|
+
// ambiguity toward FAIL, the same convention the off-package test in rules.mjs
|
|
426
|
+
// already follows, because a missed real violation is recoverable and a
|
|
427
|
+
// confident wrong one is not believed twice. An unterminated comment is also
|
|
428
|
+
// vanishingly rare next to the case this exists for.
|
|
429
|
+
export function htmlCommentRanges(text) {
|
|
430
|
+
const ranges = [];
|
|
431
|
+
let i = 0;
|
|
432
|
+
for (;;) {
|
|
433
|
+
const open = text.indexOf("<!--", i);
|
|
434
|
+
if (open === -1) break;
|
|
435
|
+
const close = text.indexOf("-->", open + 4);
|
|
436
|
+
if (close === -1) break; // unterminated: see above, NOT a comment
|
|
437
|
+
ranges.push([open, close + 3]);
|
|
438
|
+
i = close + 3;
|
|
439
|
+
}
|
|
440
|
+
return ranges;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export const insideHtmlComment = (ranges, at) => ranges.some(([s, e]) => at >= s && at < e);
|
|
444
|
+
|
|
399
445
|
// Blank out comments, keeping every other byte where it was.
|
|
400
446
|
//
|
|
401
447
|
// WHY THIS LIVES HERE rather than in the one rule that first needed it: a
|