webstore-lint 1.0.21 → 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.
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
@@ -341,6 +345,24 @@ connected to Google.
341
345
  contain the static signals Google names. Reviewers are human and see more than a
342
346
  file tree.
343
347
 
348
+ ## If you have already been rejected
349
+
350
+ The linter is free and always will be, and it tells you what is wrong. Writing
351
+ the way back through review is a separate job, and it is the paid one: a
352
+ **resubmission pack, 149 USD once**. It is the text the Web Store dashboard makes
353
+ you produce, written against the code in your own package - the single purpose
354
+ description, a justification for every permission you request, the remote code
355
+ and data use answers, and the privacy disclosures to match. Delivered by email
356
+ within two business days. If we look at your extension and cannot help, you get
357
+ refunded and told why.
358
+
359
+ Every claim in the pack points at a line in your package or a sentence in
360
+ Google's published policy, and you can check both. It is written by an AI agent,
361
+ which is the whole of what this business is; you are not being told a person did
362
+ it.
363
+
364
+ [What is in the pack](https://circadian-agent.com/webstore-lint)
365
+
344
366
  ## Provenance
345
367
 
346
368
  Policy data is generated from a dataset gathered on 2026-07-29 by direct HTTPS
package/action/report.mjs CHANGED
@@ -131,6 +131,19 @@ export function summary(result, policy) {
131
131
  }
132
132
  lines.push("");
133
133
  lines.push("Run it yourself: `npx webstore-lint ./my-extension`");
134
+ // FAILING RUNS ONLY, and it keys on `fail` rather than on the exit code. With
135
+ // `fail-on: never` the check stays green while the findings are still there,
136
+ // and the reader still has the problem the pack answers. A run with warnings
137
+ // and no failures gets nothing, and a clean run returned above this line.
138
+ if (fail) {
139
+ lines.push("");
140
+ lines.push(
141
+ "Already rejected, or trying not to be? There is a paid resubmission pack: the store " +
142
+ "listing text, permission justifications and privacy disclosures, written against the code " +
143
+ "in this package. 149 USD once, refunded if we look and cannot help. " +
144
+ "[What is in it](https://circadian-agent.com/webstore-lint)",
145
+ );
146
+ }
134
147
  return lines.join("\n");
135
148
  }
136
149
 
@@ -299,7 +299,21 @@ console.log(
299
299
  "This reads your package only. Your store listing, privacy policy page and screenshots are " +
300
300
  "where several of these are actually satisfied, and no local tool can see them.",
301
301
  );
302
- console.log("webstore-lint is free and open source: https://github.com/Circadian-agent/chrome-extension-rejection-lint\n");
302
+ console.log("webstore-lint is free and open source: https://github.com/Circadian-agent/chrome-extension-rejection-lint");
303
+ // THE PAID PACK IS MENTIONED ON FAILING RUNS ONLY, and the condition is the
304
+ // whole point. A clean run means the reader does not have this problem, and
305
+ // putting an offer in front of them there is advertising rather than an answer.
306
+ // A failing run means they are holding it right now. Keep it to three lines and
307
+ // keep the price and the refund in them: a footer that hides either is the kind
308
+ // of thing that gets a tool uninstalled.
309
+ if (fail) {
310
+ console.log("");
311
+ console.log("Already rejected, or trying not to be? There is a paid resubmission pack: the store");
312
+ console.log("listing text, permission justifications and privacy disclosures, written against the");
313
+ console.log("code in this package. 149 USD once, refunded if we look and cannot help.");
314
+ console.log("https://circadian-agent.com/webstore-lint");
315
+ }
316
+ console.log("");
303
317
  // Same trap, same reason: the human report is also large on a real extension
304
318
  // and it is also piped (`webstore-lint . | less`, or into a file in CI). Setting
305
319
  // the code instead of calling exit() lets Node drain stdout and then leave with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webstore-lint",
3
- "version": "1.0.21",
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
@@ -547,16 +547,98 @@ export const RULES = [
547
547
  // sites are real-looking calls (`eval('ale'+'rt(0)')`) that happen to sit
548
548
  // in a string, and nothing local separates them from a genuine call, so
549
549
  // they are LEFT FAILING rather than guessed at.
550
+ //
551
+ // SAME PROOF, ONE MORE SHAPE (s135, T-0413's second widening). `new
552
+ // Function("")` builds a function with an EMPTY BODY - calling it does
553
+ // nothing and returns undefined, exactly the no-op EVAL_NO_ARGS already
554
+ // carves out, just spelled with the other string-execution primitive.
555
+ // It is not a rare shape: it is pdf.js's own `isEvalSupported()` -
556
+ // `try { new Function(""); return true; } catch { return false; }` -
557
+ // vendored, byte-for-byte or minified, into every extension that bundles
558
+ // PDF rendering, and it is AngularJS's `noUnsafeEval()` doing the same
559
+ // CSP probe with `new Function('')`. Measured on this widened corpus:
560
+ // magnetgrouplabs/myjdownloader-extension-mv3 (angular.js), listen1's
561
+ // vendored angular.min.js, and pdf.js in both MLabPages/
562
+ // classroom-office-reviewer and ttop32/MouseTooltipTranslator all FAILed
563
+ // on nothing but this literal - four unrelated repos wrongly failed by
564
+ // one vendored idiom whose entire purpose is CHECKING FOR CSP-SAFETY,
565
+ // which makes the false accusation an especially bad look: the tool
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*\(\?/;
550
606
  const BUNDLER_GLOBAL_SHIM = /^new\s+Function\s*\(\s*(["'])return this\1\s*\)/;
551
607
  const EVAL_METHOD_DEF = /^eval\s*\(\s*(?:[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)?\s*\)\s*\{/;
552
608
  const EVAL_NO_ARGS = /^eval\s*\(\s*\)/;
609
+ // EVAL_METHOD_DEF ONLY CATCHES *UNTYPED* METHOD DEFINITIONS (s135, T-0413's
610
+ // second widening). Tardo/OdooTerminal declares
611
+ // `async eval(code: string, options?: Partial<EvalOptions>, isolated_frame?:
612
+ // boolean = false): Promise<any> {` on its terminal Shell class - a method
613
+ // named `eval` for evaluating a TERMINAL COMMAND, unrelated to the JS
614
+ // engine's `eval`. It is a method definition by the same logic as
615
+ // `openstyles/stylus`'s untyped one (a call is never followed by `){`... or
616
+ // here, by a TYPE), but EVAL_METHOD_DEF requires a plain identifier list
617
+ // immediately closed by `)\s*{`, and TypeScript/Flow signatures add type
618
+ // annotations, `?` optional markers, generics and default values that push
619
+ // the closing `{` well past this rule's 80-character lookahead window - so
620
+ // neither the old regex nor a wider version of it can reliably see the
621
+ // brace that proves this is a definition.
622
+ //
623
+ // THE SAFE SIGNAL DOES NOT NEED THE BRACE. `identifier:` (optionally
624
+ // `identifier?:`) directly inside the parens of `eval(...)` is not valid
625
+ // JavaScript or TypeScript CALL syntax - a bare `name: value` pair inside
626
+ // plain parens is a SyntaxError unless it is a parameter's type annotation
627
+ // in a function/method signature (an object-literal argument would need its
628
+ // own `{`, and a ternary's `cond ? a : b` always has whitespace or a `?`
629
+ // between the identifier and the colon that this pattern does not allow).
630
+ // So seeing `eval(` immediately followed by a plain identifier and a colon
631
+ // is sufficient on its own, well within the 80-character window, and cannot
632
+ // be produced by any real call to the global `eval`.
633
+ const EVAL_METHOD_DEF_TYPED = /^eval\s*\(\s*[A-Za-z_$][\w$]*\??\s*:/;
553
634
  const executesAString = (m, line) => {
554
635
  // m[1] is the leading non-identifier char of the eval branch, which is
555
636
  // part of the match but not part of the site.
556
637
  const at = m.index + (m[1] ? m[1].length : 0);
557
638
  const tail = line.slice(at, at + 80);
558
639
  return !BUNDLER_GLOBAL_SHIM.test(tail) && !EVAL_METHOD_DEF.test(tail)
559
- && !EVAL_NO_ARGS.test(tail);
640
+ && !EVAL_NO_ARGS.test(tail) && !NEW_FUNCTION_EMPTY_BODY.test(tail)
641
+ && !EVAL_METHOD_DEF_TYPED.test(tail) && !REGEX_GROUP_OPENER.test(tail);
560
642
  };
561
643
  const source = new Map(files.map((f) => [f.path, f.lines]));
562
644
  const EVAL_RE = /(^|[^.\w$])eval\s*\(|new\s+Function\s*\(/;
@@ -931,7 +1013,20 @@ export const RULES = [
931
1013
  category: "excessive-permissions",
932
1014
  run({ manifest, files, skipped = [], oversized = [] }) {
933
1015
  if (!manifest) return [];
934
- const declared = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])];
1016
+ // DEDUPED, because a manifest can and does list the same permission
1017
+ // twice. bonigarcia/browserwatcher's `permissions` array is
1018
+ // `["tabs","tabCapture","activeTab","storage","offscreen","storage"]` -
1019
+ // a genuine copy-paste duplicate in the source, found widening the
1020
+ // corpus (T-0413, s135). Without this, the finding read "Declared but
1021
+ // never used: storage, storage", which looks like the TOOL is
1022
+ // malfunctioning (double-counting) even though the underlying claim -
1023
+ // storage really is unused - was correct either time. A `Set` cannot
1024
+ // change which permissions are judged unused (the filter below still
1025
+ // sees every distinct permission name exactly once, same as before for
1026
+ // every manifest that does not have this bug), so this only removes a
1027
+ // confusing repeated entry from the reported text; it cannot turn a
1028
+ // true finding into a false one or vice versa.
1029
+ const declared = [...new Set([...(manifest.permissions || []), ...(manifest.optional_permissions || [])])];
935
1030
  // Comments blanked first. A permission whose only trace is "// we used to
936
1031
  // call chrome.bookmarks.getTree here" is the COMMONEST real form of an
937
1032
  // unused permission - the feature was deleted and the note left behind -
@@ -1194,14 +1289,68 @@ export const RULES = [
1194
1289
  "http://www.w3.org/1998/Math/MathML",
1195
1290
  "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
1196
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/",
1197
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:\/\/[^/]*\*/;
1198
1339
  const hits = grep(
1199
1340
  codeView(files),
1200
1341
  /["'`]http:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[^"'`\s]+/i,
1201
1342
  (f) => isCode(f) || isMarkup(f),
1202
- ).filter((h) => {
1203
- 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(/^["'`]/, "");
1204
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;
1205
1354
  // DOCTYPE system identifiers are the same case as namespaces and were
1206
1355
  // missed by the first pass: `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//
1207
1356
  // EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` names a
@@ -1215,7 +1364,8 @@ export const RULES = [
1215
1364
  // document is still a plain-http link and stays reported.
1216
1365
  if (/^https?:\/\/(www\.)?w3\.org\/[^"'`\s]*\.dtd$/i.test(url)) return false;
1217
1366
  return true;
1218
- });
1367
+ },
1368
+ );
1219
1369
  if (!hits.length) return [];
1220
1370
  return [finding({
1221
1371
  severity: "warn",
@@ -1419,8 +1569,46 @@ export const RULES = [
1419
1569
  return { ...f, text, lines: text.split("\n") };
1420
1570
  });
1421
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}?`;
1422
1610
  const hits = grepAcross(view, new RegExp(
1423
- 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})`,
1424
1612
  "gi"), isCode);
1425
1613
  if (!hits.length) return [];
1426
1614
  return [finding({