webstore-lint 1.0.22 → 1.0.23

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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/rules.mjs +137 -6
package/README.md CHANGED
@@ -46,6 +46,10 @@ This tool is maintained by an AI agent, and npm requires a human registrant, so
46
46
  the account was registered by a person rather than worked around. There is
47
47
  nothing to fetch beyond the package itself: it has no dependencies.
48
48
 
49
+ **Two names, one tool.** The repository is `chrome-extension-rejection-lint`,
50
+ because that is what people search for. The command you type and the npm package
51
+ are both `webstore-lint`. Nothing was forked and nothing was replaced.
52
+
49
53
  You can also install straight from this repo, which needs no registry entry and
50
54
  lets you pin to a commit:
51
55
 
@@ -57,7 +61,7 @@ Prefer a clone, or working offline:
57
61
 
58
62
  ```bash
59
63
  git clone https://github.com/Circadian-agent/chrome-extension-rejection-lint
60
- node webstore-lint/bin/webstore-lint.mjs ./my-extension
64
+ node chrome-extension-rejection-lint/bin/webstore-lint.mjs ./my-extension
61
65
  ```
62
66
 
63
67
  ## In GitHub Actions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webstore-lint",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
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
@@ -564,10 +564,48 @@ export const RULES = [
564
564
  // one vendored idiom whose entire purpose is CHECKING FOR CSP-SAFETY,
565
565
  // which makes the false accusation an especially bad look: the tool
566
566
  // fails a package for the very check that proves it is eval-aware.
567
+ //
568
+ // AND `new Function()` WITH NO ARGUMENT AT ALL IS THE SAME NO-OP, spelled
569
+ // one character shorter (s137, T-0413's third widening). It builds
570
+ // `function anonymous() {}` - there is no string, so there is nothing for
571
+ // a string to arrive from. The old pattern required an empty QUOTED
572
+ // argument and therefore walked straight past the bare call.
573
+ //
574
+ // Measured on the 750-repo corpus: six files contain it and not one is
575
+ // string execution. They are `scripts/verify-extension.js`,
576
+ // `scripts/validate.mjs`, a security ARTICLE about MV3's CSP, and three
577
+ // test files named release-validator, secure-eval and safety. Every one is
578
+ // prose or a policy check that FORBIDS this call - the same shape as the
579
+ // pdf.js CSP probe above, and the same bad look: the tool failing a
580
+ // package for the check that proves the author is eval-aware.
581
+ // Mootong/x-twitter-media-downloader was FAILed on
582
+ // assert(!/\bnew\s+Function\s*\(/.test(source), `new Function() is not allowed: ${filename}`)
583
+ // where the site is the ERROR MESSAGE of a build-time check asserting the
584
+ // shipped files contain no `new Function(` - sole evidence, at FAIL.
585
+ const NEW_FUNCTION_EMPTY_BODY = /^new\s+Function\s*\(\s*(?:(["'`])\s*\1\s*)?\)/;
586
+ // A `(` FOLLOWED IMMEDIATELY BY `?` IS NOT A CALL IN ANY JAVASCRIPT, which
587
+ // makes this the same kind of local proof as the empty argument list: `?`
588
+ // cannot begin an expression, so `eval(?` is a SyntaxError and the only
589
+ // thing that can produce it is a regex group opener - `(?:`, `(?=`, `(?!`,
590
+ // `(?<`. Two unrelated repos in the widened corpus, and both are regexes
591
+ // ABOUT eval rather than uses of it:
592
+ //
593
+ // ha0z1/New-Bing-Anywhere (2116 stars) ships a highlight.js language
594
+ // grammar in `dist/chromium/app/assets/index.js`, a 356 KB line whose
595
+ // keyword alternation contains `|eval(?:cmd)?|`. That was the SOLE
596
+ // evidence for a FAIL on a shipped build.
597
+ // magicelk235/Viaduct-CLI validates a manifest's CSP with
598
+ // `/(?:^|[\s;'"])unsafe-eval(?:[\s;'"]|$)/.test(csp)` - code whose
599
+ // entire purpose is refusing unsafe-eval.
600
+ //
601
+ // Deliberately NOT a rule about regex literals in general. Telling a regex
602
+ // apart from a division needs a parser, and this file has already refused
603
+ // to let a quote-tracking scan silence findings (T-0416); this is one
604
+ // character of local syntax and needs neither.
605
+ const REGEX_GROUP_OPENER = /^(?:eval|new\s+Function)\s*\(\?/;
567
606
  const BUNDLER_GLOBAL_SHIM = /^new\s+Function\s*\(\s*(["'])return this\1\s*\)/;
568
607
  const EVAL_METHOD_DEF = /^eval\s*\(\s*(?:[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)?\s*\)\s*\{/;
569
608
  const EVAL_NO_ARGS = /^eval\s*\(\s*\)/;
570
- const NEW_FUNCTION_EMPTY_BODY = /^new\s+Function\s*\(\s*(["'`])\s*\1\s*\)/;
571
609
  // EVAL_METHOD_DEF ONLY CATCHES *UNTYPED* METHOD DEFINITIONS (s135, T-0413's
572
610
  // second widening). Tardo/OdooTerminal declares
573
611
  // `async eval(code: string, options?: Partial<EvalOptions>, isolated_frame?:
@@ -600,7 +638,7 @@ export const RULES = [
600
638
  const tail = line.slice(at, at + 80);
601
639
  return !BUNDLER_GLOBAL_SHIM.test(tail) && !EVAL_METHOD_DEF.test(tail)
602
640
  && !EVAL_NO_ARGS.test(tail) && !NEW_FUNCTION_EMPTY_BODY.test(tail)
603
- && !EVAL_METHOD_DEF_TYPED.test(tail);
641
+ && !EVAL_METHOD_DEF_TYPED.test(tail) && !REGEX_GROUP_OPENER.test(tail);
604
642
  };
605
643
  const source = new Map(files.map((f) => [f.path, f.lines]));
606
644
  const EVAL_RE = /(^|[^.\w$])eval\s*\(|new\s+Function\s*\(/;
@@ -1251,14 +1289,68 @@ export const RULES = [
1251
1289
  "http://www.w3.org/1998/Math/MathML",
1252
1290
  "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
1253
1291
  "http://www.w3.org/2001/XMLSchema",
1292
+ // s137, T-0413's third widening. The eight above are all w3.org, so the
1293
+ // list encoded "specification namespace" as "a URL at w3.org" and the
1294
+ // wider corpus walked round it immediately: OOXML (every extension that
1295
+ // touches .docx or .xlsx), WS-Trust and XMLDSig (SAML tooling), EPUB,
1296
+ // TTML and Inkscape's SVG extension namespace all arrive at other hosts
1297
+ // and are names by exactly the same argument - fixed by a specification,
1298
+ // never fetched, and unchangeable to https even in principle.
1299
+ "http://schemas.openxmlformats.org/",
1300
+ "http://schemas.microsoft.com/office/",
1301
+ "http://docs.oasis-open.org/ws-sx/",
1302
+ "http://docs.oasis-open.org/wss/",
1303
+ "http://www.w3.org/2000/09/xmldsig#",
1304
+ "http://www.w3.org/ns/ttml",
1305
+ "http://www.idpf.org/2007/opf",
1306
+ "http://www.idpf.org/2007/ops",
1307
+ "http://www.inkscape.org/namespaces/inkscape",
1308
+ "http://sodipodi.sourceforge.net/DTD/",
1309
+ "http://purl.org/dc/elements/",
1310
+ "http://ns.adobe.com/",
1254
1311
  ];
1312
+ // A LIST OF HOSTS CANNOT KEEP UP, so the general form is POSITION rather
1313
+ // than authority. These three syntactic slots hold a NAME by definition:
1314
+ //
1315
+ // xmlns="..." / xmlns:prefix="..." an XML namespace declaration
1316
+ // createElementNS("...", ...) the namespace argument of the DOM
1317
+ // getElementsByTagNameNS("...", ...) *NS family - always argument one
1318
+ // <!DOCTYPE x PUBLIC "..." "..."> a DTD system identifier
1319
+ //
1320
+ // None of them is a channel anything travels over, none can be rewritten
1321
+ // to https without breaking the document, and no amount of user data can
1322
+ // flow to one. This is what the w3.org/.dtd special case below was
1323
+ // reaching for one host at a time.
1324
+ //
1325
+ // ANCHORED TO WHAT PRECEDES THE OPENING QUOTE, so it cannot exonerate a
1326
+ // fetch: `fetch("http://x")` has `fetch(` before the quote and matches
1327
+ // none of these.
1328
+ const NAME_POSITION =
1329
+ /(?:xmlns(?::[A-Za-z_][\w.\-]*)?\s*=\s*|[A-Za-z]*NS\s*\(\s*|PUBLIC\s+(["'])[^"']*\1\s*|SYSTEM\s+)$/;
1330
+ // A URL WHOSE AUTHORITY CONTAINS `*` IS A MATCH PATTERN, NOT AN ENDPOINT.
1331
+ // `matches: ["http://*/*"]`, `urls: ["http://*/*"]`, documentUrlPatterns,
1332
+ // optional_host_permissions - 26 of the 181 evidence entries this rule
1333
+ // produced over the 750-repo corpus. There is no host to connect to, so
1334
+ // no user data can be sent over it, and "make it https" is not a fix but a
1335
+ // change to which pages the extension covers. Broad access IS reported,
1336
+ // by broad-host-permissions, which reads the manifest keys where it means
1337
+ // something.
1338
+ const WILDCARD_AUTHORITY = /^http:\/\/[^/]*\*/;
1255
1339
  const hits = grep(
1256
1340
  codeView(files),
1257
1341
  /["'`]http:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[^"'`\s]+/i,
1258
1342
  (f) => isCode(f) || isMarkup(f),
1259
- ).filter((h) => {
1260
- const url = String(h.match || "").replace(/^["'`]/, "");
1343
+ // PER MATCH, so the scan continues along the same line - see grep() in
1344
+ // scan.mjs. As a post-filter this dropped the whole line when its FIRST
1345
+ // http:// was a namespace, so a real endpoint sharing a line with an
1346
+ // `xmlns` attribute went unreported. An `accept` callback resumes at the
1347
+ // next match instead, the same reason the remote-code skips are written
1348
+ // this way (T-0414).
1349
+ (m, line) => {
1350
+ const url = String(m[0] || "").replace(/^["'`]/, "");
1261
1351
  if (XML_NAMESPACES.some((ns) => url.startsWith(ns))) return false;
1352
+ if (WILDCARD_AUTHORITY.test(url)) return false;
1353
+ if (NAME_POSITION.test(line.slice(Math.max(0, m.index - 80), m.index))) return false;
1262
1354
  // DOCTYPE system identifiers are the same case as namespaces and were
1263
1355
  // missed by the first pass: `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//
1264
1356
  // EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` names a
@@ -1272,7 +1364,8 @@ export const RULES = [
1272
1364
  // document is still a plain-http link and stays reported.
1273
1365
  if (/^https?:\/\/(www\.)?w3\.org\/[^"'`\s]*\.dtd$/i.test(url)) return false;
1274
1366
  return true;
1275
- });
1367
+ },
1368
+ );
1276
1369
  if (!hits.length) return [];
1277
1370
  return [finding({
1278
1371
  severity: "warn",
@@ -1476,8 +1569,46 @@ export const RULES = [
1476
1569
  return { ...f, text, lines: text.split("\n") };
1477
1570
  });
1478
1571
  const redirect = String.raw`(?:chrome|browser)\.tabs\.update\s*\(`;
1572
+ // AND THE WINDOW MAY NOT CROSS A BLOCK THAT CLOSES (s137, T-0413's third
1573
+ // widening). 200 characters was picked without a measurement, and the
1574
+ // measurement arrived as this rule's FIRST firing in any corpus run we
1575
+ // have: sindresorhus/notifier-for-github (1978 stars), at FAIL, on
1576
+ // `source/lib/tabs-service.js`, where the two halves are
1577
+ //
1578
+ // line 7: 'chrome://newtab/', <- an element of emptyTabUrls
1579
+ // line 16: return browser.tabs.update(tabId, options);
1580
+ //
1581
+ // and everything between them is `] : [];`, a blank line, and a COMPLETE
1582
+ // `createTab` function. The gap is 193 characters, so the finding existed
1583
+ // by a margin of seven. What that extension does with the list is reuse a
1584
+ // blank tab you already have instead of opening another one - the opposite
1585
+ // of replacing what you get when you open a new tab - and the
1586
+ // `tabs.update` it was paired with is a two-line generic wrapper whose
1587
+ // destination is a parameter.
1588
+ //
1589
+ // The header above already states the intent this misses: the window is
1590
+ // there "so a predicate in one function [does not] pair with a navigation
1591
+ // in an unrelated one further down the file". A character count cannot
1592
+ // express that; a closing brace or bracket at the start of a line can. If
1593
+ // a block ENDED between the two sites they are not on one control-flow
1594
+ // path, and a hijack has to be - the test and the redirect it guards live
1595
+ // in the same body.
1596
+ //
1597
+ // IT IS A NO-OP ON MINIFIED CODE, which is the safe direction: a bundle
1598
+ // has no line starts, so nothing between two sites can match and every
1599
+ // shipped-bundle hijack is judged exactly as before. Shrinking the 200
1600
+ // instead would have hit minified and readable code alike while still
1601
+ // being a number nobody could defend.
1602
+ //
1603
+ // THE LIMIT, STATED RATHER THAN DISCOVERED: a hijack that closes a block
1604
+ // between the guard and the redirect - `if (isNtp(u)) { log(); }` and then
1605
+ // an unguarded `tabs.update` - is now a miss. It joins the aliased-API and
1606
+ // non-tabs.update misses the header already names. Weighed against a
1607
+ // wrong FAIL on a well-known extension, on a rule that has produced no
1608
+ // true positive in any corpus run, that is the trade this file keeps making.
1609
+ const gap = String.raw`(?:(?!\n[\t ]*[}\]])[\s\S]){0,200}?`;
1479
1610
  const hits = grepAcross(view, new RegExp(
1480
- String.raw`(?:${NTP})[\s\S]{0,200}?${redirect}` + "|" + String.raw`${redirect}[\s\S]{0,200}?(?:${NTP})`,
1611
+ String.raw`(?:${NTP})${gap}${redirect}` + "|" + String.raw`${redirect}${gap}(?:${NTP})`,
1481
1612
  "gi"), isCode);
1482
1613
  if (!hits.length) return [];
1483
1614
  return [finding({