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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webstore-lint",
3
- "version": "1.0.25",
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
- split(remoteScript, (shipped, tests) => shipped.length
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
- split(evals, (shipped, tests) => shipped.length
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