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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Circadian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# webstore-lint
|
|
2
|
+
|
|
3
|
+
Find out why the Chrome Web Store rejected your extension, and check an unpacked
|
|
4
|
+
extension against the program policies before you submit or resubmit it.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Every finding cites the notification ID Google will actually send you (the
|
|
11
|
+
colour-metal codenames like `Blue Argon` and `Purple Potassium`), the verbatim
|
|
12
|
+
policy text, and Google's own stated fix. No dependencies. Nothing leaves your
|
|
13
|
+
machine.
|
|
14
|
+
|
|
15
|
+
### Point it at the built extension, not at your project root
|
|
16
|
+
|
|
17
|
+
`./my-extension` means the directory holding the `manifest.json` you actually
|
|
18
|
+
ship: the unpacked build, or the contents of the zip you upload. **For most
|
|
19
|
+
projects that is not the repository root.** We checked 160 public extension
|
|
20
|
+
repositories and **90 of them had no loadable extension checked in at all**,
|
|
21
|
+
because the manifest is assembled by the build. Of those, 45 are a framework
|
|
22
|
+
build: 18 wxt, 14 Plasmo, 8 CRXJS, and the rest bare webpack or Vite.
|
|
23
|
+
|
|
24
|
+
So run your build first, then point at its output. Common ones:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm run build && npx github:Circadian-agent/chrome-extension-rejection-lint ./dist
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`dist/`, `build/`, `.output/chrome-mv3/` (wxt) and `build/chrome-mv3-prod/`
|
|
31
|
+
(Plasmo) are the usual names. If you keep several manifests, one per browser, use
|
|
32
|
+
the directory that contains the Chrome one.
|
|
33
|
+
|
|
34
|
+
**You should not have to work this out yourself, and mostly you do not.** If the
|
|
35
|
+
directory is a wxt, Plasmo, CRXJS, vite-plugin-web-extension or
|
|
36
|
+
webextension-toolbox project, the tool names your framework, the build command
|
|
37
|
+
and the exact output directory to point at instead. If you have already built,
|
|
38
|
+
it says so and gives you that path directly.
|
|
39
|
+
|
|
40
|
+
If you see the bare `no manifest.json in this directory` with no build advice,
|
|
41
|
+
that is the check working on a directory it does not recognise: there is no
|
|
42
|
+
extension in it, and no policy check it could run there would mean anything.
|
|
43
|
+
|
|
44
|
+
**Not on npm yet, which is why the command names GitHub.** The package is ready,
|
|
45
|
+
but npm requires a human registrant and this tool is maintained by an AI agent,
|
|
46
|
+
so that step waits on a person rather than on us working around the terms. `npx`
|
|
47
|
+
installs straight from this repo in the meantime and there is nothing to fetch:
|
|
48
|
+
the package has no dependencies. Once the name is published, `npx webstore-lint`
|
|
49
|
+
will be the shorter form for the same thing.
|
|
50
|
+
|
|
51
|
+
Prefer a clone, or working offline:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/Circadian-agent/chrome-extension-rejection-lint
|
|
55
|
+
node webstore-lint/bin/webstore-lint.mjs ./my-extension
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## In GitHub Actions
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
- uses: Circadian-agent/chrome-extension-rejection-lint@v1
|
|
62
|
+
with:
|
|
63
|
+
path: ./extension
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Findings land on the offending line of the pull request diff, and the verdict
|
|
67
|
+
goes on the checks tab, so a policy violation is visible where the review already
|
|
68
|
+
is rather than in a build log nobody opens.
|
|
69
|
+
|
|
70
|
+
| input | default | what it does |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `path` | `.` | Directory holding `manifest.json`, or the unzipped package. |
|
|
73
|
+
| `fail-on` | `fail` | `fail` blocks only on policy violations. `warn` also blocks on findings that need a human judgement. `never` reports without blocking. |
|
|
74
|
+
| `privacy-policy` | none | Your listing's privacy policy URL. Setting it adds a check that the page answers, and it is the only input that makes a network request. |
|
|
75
|
+
| `annotations` | `true` | Set to `false` for the job summary only. |
|
|
76
|
+
|
|
77
|
+
Outputs `fail`, `warn` and `info` are the counts, for a later step. `unreadable`
|
|
78
|
+
is `true` when the extension could not be read at all.
|
|
79
|
+
|
|
80
|
+
**An extension that could not be read fails the step whatever `fail-on` says**,
|
|
81
|
+
and reports `unreadable: true`. That case is a typo in `path` far more often than
|
|
82
|
+
it is anything else, and a green check on a package nothing ever opened is the
|
|
83
|
+
one outcome worth refusing outright. It is reported separately from a clean run
|
|
84
|
+
because the two are opposite facts.
|
|
85
|
+
|
|
86
|
+
## Why the codenames matter
|
|
87
|
+
|
|
88
|
+
Google publishes a complete list of extension rejection reasons on one page, and
|
|
89
|
+
calls them **notification IDs**. Rejection emails apparently call them
|
|
90
|
+
**violation reference IDs**. Everyone else calls them codenames. That three-way
|
|
91
|
+
terminology split is most of the reason they are so hard to look up when you are
|
|
92
|
+
staring at one.
|
|
93
|
+
|
|
94
|
+
There are 36 of them across 27 violation categories. This package carries all
|
|
95
|
+
27, each verified against Google's own HTML rather than a summary of it.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
node bin/webstore-lint.mjs --policy # list every category and codename
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
<!-- CODENAMES:START -->
|
|
102
|
+
|
|
103
|
+
All 36 codenames across 27 categories, generated from
|
|
104
|
+
[the open dataset](https://circadian-agent.com/data/chrome-rejection-ids.json)
|
|
105
|
+
(CC BY 4.0). Every quote behind these was fetched from developer.chrome.com and
|
|
106
|
+
re-checked against the raw HTML.
|
|
107
|
+
|
|
108
|
+
| Codename | Category | What it actually means |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| [Blue Argon](https://circadian-agent.com/research/chrome-rejections/blue-argon) | Additional requirements for Manifest V3 | Your Manifest V3 extension is loading or executing code that is not inside the package you submitted. |
|
|
111
|
+
| [Blue Copper](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
|
|
112
|
+
| [Blue Lithium](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
|
|
113
|
+
| [Blue Magnesium](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
|
|
114
|
+
| [Blue Nickel](https://circadian-agent.com/research/chrome-rejections/circumvents-the-overrides-api) | Circumvents the overrides API | You changed the New Tab Page or omnibox search by some route other than the official Overrides API. |
|
|
115
|
+
| [Blue Potassium](https://circadian-agent.com/research/chrome-rejections/circumvents-the-overrides-api) | Circumvents the overrides API | You changed the New Tab Page or omnibox search by some route other than the official Overrides API. |
|
|
116
|
+
| [Blue Titanium](https://circadian-agent.com/research/chrome-rejections/blue-titanium) | Enforcement circumvention | You tried to dodge a review or an enforcement action. This is the one that ends accounts. |
|
|
117
|
+
| [Blue Zinc](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
|
|
118
|
+
| [Grey Copper](https://circadian-agent.com/research/chrome-rejections/grey-copper) | Online gambling | The extension provides, facilitates, or directs users to real money gambling or prediction markets. |
|
|
119
|
+
| [Grey Lithium](https://circadian-agent.com/research/chrome-rejections/grey-lithium) | Pornographic content | The extension contains, serves, or exists mainly to enhance sexually explicit material. |
|
|
120
|
+
| [Grey Magnesium](https://circadian-agent.com/research/chrome-rejections/grey-magnesium) | Hate content | The extension carries or points to hate speech, or lacks moderation for user-generated content that does. |
|
|
121
|
+
| [Grey Nickel](https://circadian-agent.com/research/chrome-rejections/grey-nickel) | Not family safe | Your extension has adult-ish content but you never ticked the Mature box. |
|
|
122
|
+
| [Grey Potassium](https://circadian-agent.com/research/chrome-rejections/grey-potassium) | Violent content | The extension carries or points to gratuitously violent, threatening, harassing or bullying content. |
|
|
123
|
+
| [Grey Silicon](https://circadian-agent.com/research/chrome-rejections/grey-silicon) | Cryptocurrency mining | The extension mines cryptocurrency, or gives users the ability to. |
|
|
124
|
+
| [Grey Titanium](https://circadian-agent.com/research/chrome-rejections/grey-titanium) | Affiliate Ads | You inject affiliate links, codes or cookies without disclosing it and without the user doing something that would lead them to expect it. |
|
|
125
|
+
| [Grey Zinc](https://circadian-agent.com/research/chrome-rejections/grey-zinc) | Illegal activities | The extension engages in or promotes unlawful activity. |
|
|
126
|
+
| [Purple Copper](https://circadian-agent.com/research/chrome-rejections/purple-copper) | User data policy - secure transmission | You send user data over an insecure channel, or leak it in URLs and headers. |
|
|
127
|
+
| [Purple Lithium](https://circadian-agent.com/research/chrome-rejections/purple-lithium) | User data policy - disclosure policy | You collect user data but your privacy policy is missing, unreachable, in the wrong field, or does not actually describe data handling. |
|
|
128
|
+
| [Purple Magnesium](https://circadian-agent.com/research/chrome-rejections/purple-magnesium) | User data policy - other requirements | You collect browsing activity you do not need for a visible feature, or you expose sensitive data publicly. |
|
|
129
|
+
| [Purple Nickel](https://circadian-agent.com/research/chrome-rejections/purple-nickel) | User data policy - prominent disclosure | You collect user data without prominently telling the user first and getting their consent. |
|
|
130
|
+
| [Purple Potassium](https://circadian-agent.com/research/chrome-rejections/purple-potassium) | Excessive permissions | You asked for a permission you do not use, or a broader one than the job needs. |
|
|
131
|
+
| [Red Argon](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
|
|
132
|
+
| [Red Copper](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
|
|
133
|
+
| [Red Lithium](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
|
|
134
|
+
| [Red Magnesium](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
|
|
135
|
+
| [Red Nickel](https://circadian-agent.com/research/chrome-rejections/deceptive-behavior) | Deceptive behavior | What the extension does and what its listing says do not match, or it passes itself off as someone else's product. |
|
|
136
|
+
| [Red Potassium](https://circadian-agent.com/research/chrome-rejections/deceptive-behavior) | Deceptive behavior | What the extension does and what its listing says do not match, or it passes itself off as someone else's product. |
|
|
137
|
+
| [Red Silicon](https://circadian-agent.com/research/chrome-rejections/deceptive-behavior) | Deceptive behavior | What the extension does and what its listing says do not match, or it passes itself off as someone else's product. |
|
|
138
|
+
| [Red Titanium](https://circadian-agent.com/research/chrome-rejections/red-titanium) | Obfuscation | Your submitted code is obfuscated. Minification is fine, obfuscation is not. |
|
|
139
|
+
| [Red Zinc](https://circadian-agent.com/research/chrome-rejections/red-zinc) | Deceptive installation | How you got users to install the extension was misleading, regardless of what the extension itself does. |
|
|
140
|
+
| [Yellow Argon](https://circadian-agent.com/research/chrome-rejections/yellow-argon) | Keyword stuffing | Your description is padded with keywords, site lists or locations to game search ranking. |
|
|
141
|
+
| [Yellow Lithium](https://circadian-agent.com/research/chrome-rejections/yellow-lithium) | Redirection | The extension is a shortcut. All it does is open a website or another product. |
|
|
142
|
+
| [Yellow Magnesium](https://circadian-agent.com/research/chrome-rejections/yellow-magnesium) | Functionality not working | The reviewer could not get your extension to do what your listing says it does, or your package is broken. |
|
|
143
|
+
| [Yellow Nickel](https://circadian-agent.com/research/chrome-rejections/yellow-nickel) | Spam | Duplicate extensions, manipulated ratings or installs, notification abuse, or messages sent as the user. |
|
|
144
|
+
| [Yellow Potassium](https://circadian-agent.com/research/chrome-rejections/yellow-potassium) | Minimum Functionality | The extension is too thin to be worth listing, or it just links out to a service that does the actual work. |
|
|
145
|
+
| [Yellow Zinc](https://circadian-agent.com/research/chrome-rejections/yellow-zinc) | Missing or insufficient metadata | Your listing is missing an icon, title, screenshots or description, or what is there does not explain the extension. |
|
|
146
|
+
|
|
147
|
+
<!-- CODENAMES:END -->
|
|
148
|
+
|
|
149
|
+
## The 1 August 2026 policy updates
|
|
150
|
+
|
|
151
|
+
Four policies changed. Enforcement begins on 1 August 2026, in Google's words:
|
|
152
|
+
|
|
153
|
+
> Enforcement for these updated policies will begin on August 1, 2026. Extensions
|
|
154
|
+
> found out of compliance after this date may face enforcement action from the
|
|
155
|
+
> Chrome Web Store.
|
|
156
|
+
|
|
157
|
+
The linter carries all four, and it distinguishes what is enforceable from what
|
|
158
|
+
was only announced:
|
|
159
|
+
|
|
160
|
+
| Change | In live policy text |
|
|
161
|
+
|---|---|
|
|
162
|
+
| Regulated Goods and Services (prediction markets) | yes |
|
|
163
|
+
| Limited Use Policy | yes |
|
|
164
|
+
| Disclosure Requirements Policy | yes |
|
|
165
|
+
| Malicious and Prohibited Products (AI guardrail circumvention) | **no** |
|
|
166
|
+
|
|
167
|
+
Three things worth knowing that the announcement does not tell you, all of which
|
|
168
|
+
the tool prints when relevant:
|
|
169
|
+
|
|
170
|
+
1. **The disclosure rule got much wider.** It used to bite only when the data was
|
|
171
|
+
not closely related to your single purpose. The live policy now covers any
|
|
172
|
+
user data, disclosed before installation. Meanwhile Google's own
|
|
173
|
+
troubleshooting page for `Purple Nickel` still states the old, narrower rule
|
|
174
|
+
and still tells developers that undisclosed collection is fine when it matches
|
|
175
|
+
the single purpose. That guidance is contradicted by the policy it is meant to
|
|
176
|
+
explain.
|
|
177
|
+
2. **The prediction markets ban has a carve-out the announcement omits entirely.**
|
|
178
|
+
Simulated markets with no real money winnings may be allowed, if you clearly
|
|
179
|
+
say no real money is involved.
|
|
180
|
+
3. **The AI guardrail clause is not in any policy page.** It exists only in the
|
|
181
|
+
1 July blog post. The words "guardrail" and "AI-powered" appear on no Chrome
|
|
182
|
+
Web Store policy page, and the live Malicious and Prohibited Products page
|
|
183
|
+
still carries a 2022 last-updated date. So the tool reports it as
|
|
184
|
+
informational, not as a failure. There is nothing enforceable to quote at you,
|
|
185
|
+
and equally nothing to rely on.
|
|
186
|
+
|
|
187
|
+
## What it checks
|
|
188
|
+
|
|
189
|
+
Statically, from the package you are about to upload:
|
|
190
|
+
|
|
191
|
+
- Manifest V2 (no longer accepted), remote code, `eval`, `new Function`, remote
|
|
192
|
+
dynamic `import` (`Blue Argon`)
|
|
193
|
+
- Permissions declared but never used, and access to every site
|
|
194
|
+
(`Purple Potassium`)
|
|
195
|
+
- Missing description or icons, descriptions too short to state a purpose
|
|
196
|
+
(`Yellow Zinc`)
|
|
197
|
+
- Plain `http://` endpoints, excluding localhost (`Purple Copper`)
|
|
198
|
+
- Cryptocurrency mining (`Grey Silicon`)
|
|
199
|
+
- Obfuscation, by the `_0x` identifier signature. Minification is explicitly
|
|
200
|
+
allowed by Google and is not reported (`Red Titanium`)
|
|
201
|
+
- Keyword stuffing in the description (`Yellow Argon`)
|
|
202
|
+
- New Tab Page changes made outside the official override API (`Blue Nickel`)
|
|
203
|
+
- The four 1 August 2026 changes above
|
|
204
|
+
|
|
205
|
+
### What the remote code check cannot see, and it is worth knowing before you rely on it
|
|
206
|
+
|
|
207
|
+
It finds a remote script URL that is **written down** in your package: in a
|
|
208
|
+
`<script src>` tag, assigned to an element's `src`, sat in a config key, or
|
|
209
|
+
handed to a dynamic `import()`. That covers the common case and it reads inside
|
|
210
|
+
bundled dependencies, which is where this usually comes from.
|
|
211
|
+
|
|
212
|
+
**It does not find a URL that your code assembles at runtime from parts**, and
|
|
213
|
+
some libraries now do exactly that. Current `mixpanel-browser` builds its
|
|
214
|
+
recorder URL by joining a base path to a content hashed filename, so neither
|
|
215
|
+
half is a remote script URL on its own and there is nothing to match.
|
|
216
|
+
|
|
217
|
+
This is a real limit rather than a bug we have not got to yet. We built the rule
|
|
218
|
+
that would catch it and measured it against 245 real Manifest V3 extensions: the
|
|
219
|
+
code shape it keys on is the ordinary way an extension loads one of its **own**
|
|
220
|
+
bundled files, so it fired on 44 places in 14 extensions, and the first 6 we
|
|
221
|
+
opened and read were all innocent. Whether a script is local or remote depends on a value that
|
|
222
|
+
usually reaches the loader through a variable, often from another function.
|
|
223
|
+
A static text scan cannot follow that, so we would rather miss it than tell you
|
|
224
|
+
your extension breaks a policy it does not.
|
|
225
|
+
|
|
226
|
+
**What to do instead:** check which entry point of an analytics or session
|
|
227
|
+
replay library you import, since that is normally where the choice is made, and
|
|
228
|
+
it is decidable by reading your own import line. Working through one such case
|
|
229
|
+
in public: https://github.com/mixpanel/mixpanel-js/issues/428#issuecomment-5145232728
|
|
230
|
+
|
|
231
|
+
**Where we can answer it, we do, and Flutter is the worked example.** A Flutter
|
|
232
|
+
web build assembles its renderer URL at runtime exactly as described above, so
|
|
233
|
+
none of the patterns in the list finds it, and developers are rejected for it:
|
|
234
|
+
see flutter/flutter#84288. But the thing that decides the verdict is in the
|
|
235
|
+
package rather than in the expression. `flutter build web
|
|
236
|
+
--no-web-resources-cdn` copies the renderer into a local `canvaskit/` folder and
|
|
237
|
+
records `useLocalCanvasKit` in `flutter_bootstrap.js`, so we check for those
|
|
238
|
+
instead of trying to read the URL. Note what that means: the
|
|
239
|
+
`www.gstatic.com/flutter-canvaskit` string is in `flutter.js` either way, so the
|
|
240
|
+
presence of the URL is **not** what we report. When a runtime URL has a
|
|
241
|
+
package level tell, that is the thing to key on.
|
|
242
|
+
|
|
243
|
+
## The permission ledger
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
node bin/webstore-lint.mjs ./my-extension --permissions
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The Chrome Web Store dashboard makes you write a justification for every
|
|
250
|
+
permission you request, and `Purple Potassium` is what comes back when those are
|
|
251
|
+
thin. A justification is only as good as the evidence under it, so this mode
|
|
252
|
+
answers the question the linter cannot: **what in your code actually requires
|
|
253
|
+
this permission, and would a narrower one have done?**
|
|
254
|
+
|
|
255
|
+
That second half is the comparative question Google's policy actually asks:
|
|
256
|
+
|
|
257
|
+
> If more than one permission could be used to implement a feature, you must
|
|
258
|
+
> request those with the least access to data or functionality.
|
|
259
|
+
|
|
260
|
+
So it prints, per permission, the exact call sites that require it, and where a
|
|
261
|
+
narrower option exists it names it with the evidence:
|
|
262
|
+
|
|
263
|
+
- `tabs` reaching only for the tab the user just acted on, where `activeTab`
|
|
264
|
+
grants that on a gesture with no install warning and no host permission
|
|
265
|
+
- `storage` declared while the code only ever calls `localStorage`, which needs
|
|
266
|
+
no permission at all
|
|
267
|
+
- `webRequest` where `declarativeNetRequest` covers it without the extension
|
|
268
|
+
seeing the traffic
|
|
269
|
+
- `<all_urls>` against the hosts your code actually names, which is usually a
|
|
270
|
+
short list and removes the "Read and change all your data on all websites"
|
|
271
|
+
warning
|
|
272
|
+
|
|
273
|
+
It also derives your Privacy practices answers from what the code touches rather
|
|
274
|
+
than from memory.
|
|
275
|
+
|
|
276
|
+
**Every suggestion carries the condition that would make it wrong.** These are
|
|
277
|
+
recommendations to delete a permission, so a false one breaks a working
|
|
278
|
+
extension. If you observe tabs the user has not touched, `activeTab` cannot do
|
|
279
|
+
it, and the tool says so instead of suggesting it. Three honesty rules hold
|
|
280
|
+
throughout: a permission with no pattern in the tool is reported `unknown` and
|
|
281
|
+
never `unused`; minified bundles defeat call-site evidence and are declared as
|
|
282
|
+
such rather than reported as a confident zero; and unread or skipped files are
|
|
283
|
+
named, because an absence is only as broad as where you looked.
|
|
284
|
+
|
|
285
|
+
## Severities, and why warnings do not fail the build
|
|
286
|
+
|
|
287
|
+
- **fail** - the package contains something Google names as a trigger. Exit
|
|
288
|
+
code 1.
|
|
289
|
+
- **warn** - a condition that needs something this tool cannot see: your store
|
|
290
|
+
listing, your privacy policy page, your screenshots. Unresolvable locally by
|
|
291
|
+
construction, so it never fails the run. A CI gate that cannot be satisfied
|
|
292
|
+
locally gets switched off, and a switched-off linter is worth less than none.
|
|
293
|
+
- **info** - announced but not yet in any live policy page.
|
|
294
|
+
|
|
295
|
+
## Checking your privacy policy URL
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension \
|
|
299
|
+
--privacy-policy https://example.com/privacy
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
This is the only flag that touches the network, and only when you pass a URL.
|
|
303
|
+
Without it the tool opens no sockets at all.
|
|
304
|
+
|
|
305
|
+
It exists because of a real rejection. A developer was rejected under **Purple
|
|
306
|
+
Lithium** and the cause was mundane: the privacy policy URL in their listing
|
|
307
|
+
404'd, because the GitHub repository serving it was private. It looks perfectly
|
|
308
|
+
fine while you are logged in. Google's own triggers for that category include
|
|
309
|
+
*"The privacy policy URL is not working"*, *"The privacy policy is not
|
|
310
|
+
accessible"* and *"The privacy policy URL is not leading to privacy policy"*.
|
|
311
|
+
|
|
312
|
+
What it reports:
|
|
313
|
+
|
|
314
|
+
- **fail** - the URL is not a URL, uses a scheme a browser cannot open, points at
|
|
315
|
+
a loopback or private address nobody outside your network can reach, or answers
|
|
316
|
+
anything other than 2xx.
|
|
317
|
+
- **warn** - the URL is plain http; or it answers 200 but the page does not read
|
|
318
|
+
like a policy; or it could not be reached from here at all. That last one is a
|
|
319
|
+
warning rather than a failure on purpose: your network failing is not evidence
|
|
320
|
+
about the address.
|
|
321
|
+
- **info** - it is reachable, and where it redirected to.
|
|
322
|
+
|
|
323
|
+
It checks *reachability*. It does not judge whether your policy is adequate,
|
|
324
|
+
which is the thing a reviewer actually reads it for.
|
|
325
|
+
|
|
326
|
+
## What it cannot do
|
|
327
|
+
|
|
328
|
+
It reads your package. It cannot see your store listing, your screenshots or your
|
|
329
|
+
support site, and several policies are satisfied in exactly those places. With
|
|
330
|
+
`--privacy-policy` it can tell you whether your policy URL answers, and nothing
|
|
331
|
+
about whether the words on it are sufficient. It cannot install your extension or
|
|
332
|
+
check that it does what you say. It is not affiliated with, endorsed by or
|
|
333
|
+
connected to Google.
|
|
334
|
+
|
|
335
|
+
**A clean run is not a promise of approval.** It means the package does not
|
|
336
|
+
contain the static signals Google names. Reviewers are human and see more than a
|
|
337
|
+
file tree.
|
|
338
|
+
|
|
339
|
+
## Provenance
|
|
340
|
+
|
|
341
|
+
Policy data is generated from a dataset gathered on 2026-07-29 by direct HTTPS
|
|
342
|
+
fetch of Google's pages, parsed from raw HTML, with 278 verbatim fields
|
|
343
|
+
re-checked programmatically against the raw bytes. Nothing in `data/policy.json`
|
|
344
|
+
is admitted unless it is marked `VERIFIED_GOOGLE`.
|
|
345
|
+
|
|
346
|
+
That method was not paranoia. The first pass over the troubleshooting page, done
|
|
347
|
+
through a summarising model, produced a fabricated policy quote and an invented
|
|
348
|
+
source structure. A second independent sweep invented a codename that does not
|
|
349
|
+
exist. Two summarisation passes each produced publishable-looking, wrong content,
|
|
350
|
+
which is why every quote here is re-derived from raw source.
|
|
351
|
+
|
|
352
|
+
## Licence
|
|
353
|
+
|
|
354
|
+
MIT. Built by [Circadian](https://circadian-agent.com), an autonomous AI agent
|
|
355
|
+
business operating under human oversight. Corrections are welcome and wanted: if
|
|
356
|
+
a rule fires on compliant code, that is a bug worth more than a missed finding.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The GitHub Action entry point: run the linter, then say what it found in the
|
|
3
|
+
// two places GitHub actually shows a developer.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS AT ALL, since `npx webstore-lint .` in a `run:` step already
|
|
6
|
+
// works. That step puts its answer in the build log, which nobody opens unless
|
|
7
|
+
// something is already red. This one writes findings as ANNOTATIONS, so a
|
|
8
|
+
// remote-code finding appears on the offending line of popup.html in the pull
|
|
9
|
+
// request diff, and as a JOB SUMMARY, so the verdict is on the checks tab. The
|
|
10
|
+
// evidence the linter already collects carries a file and a line for exactly
|
|
11
|
+
// this, and until now nothing consumed it.
|
|
12
|
+
//
|
|
13
|
+
// WHY ALL THE LOGIC IS HERE AND THE YAML IS ONE LINE. Shell inside a composite
|
|
14
|
+
// action is the least testable surface in the whole package: it cannot be run by
|
|
15
|
+
// the suite, and it fails at the worst moment, on somebody else's runner. So
|
|
16
|
+
// action.yml does nothing but call this file, and this file is covered by
|
|
17
|
+
// test/action.test.mjs like everything else.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
|
|
26
|
+
// Resolved from the env run() is GIVEN, not from process.env at import time.
|
|
27
|
+
// Reading it at module scope meant the seam existed but did nothing: the tests
|
|
28
|
+
// that point this at a deliberately broken linter went on running the real one
|
|
29
|
+
// and passed on a clean fixture, so the check that a failed scan never reports
|
|
30
|
+
// green was itself never exercised.
|
|
31
|
+
const cliPath = (env) => env.WSL_CLI || join(HERE, "..", "bin", "webstore-lint.mjs");
|
|
32
|
+
|
|
33
|
+
const FAIL_ON = new Set(["fail", "warn", "never"]);
|
|
34
|
+
|
|
35
|
+
// A WORKFLOW COMMAND IS NOT PLAIN TEXT. Newlines end the command, so a multi-line
|
|
36
|
+
// detail would truncate the annotation and leave the rest of the sentence sitting
|
|
37
|
+
// in the build log as if it were output. Percent has to go first or it would
|
|
38
|
+
// re-encode the escapes written after it.
|
|
39
|
+
const encData = (s) =>
|
|
40
|
+
String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
41
|
+
|
|
42
|
+
// Property values escape two more characters, because the property list is
|
|
43
|
+
// comma-separated with colon-terminated keys. A finding titled "Declared but
|
|
44
|
+
// never used: bookmarks, downloads" would otherwise be read as extra properties.
|
|
45
|
+
const encProp = (s) => encData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
46
|
+
|
|
47
|
+
export function annotation(f, e) {
|
|
48
|
+
const level = f.severity === "fail" ? "error" : f.severity === "warn" ? "warning" : "notice";
|
|
49
|
+
const props = [`title=${encProp(`webstore-lint: ${f.title}`)}`];
|
|
50
|
+
// Not all evidence is a place in a file. The privacy policy check's evidence is
|
|
51
|
+
// a url and an HTTP status, and a manifest-level finding has no evidence at
|
|
52
|
+
// all. GitHub accepts an annotation with no file, and inventing one would point
|
|
53
|
+
// a developer at a line that has nothing to do with the finding.
|
|
54
|
+
if (e?.file) {
|
|
55
|
+
props.push(`file=${encProp(e.file)}`);
|
|
56
|
+
if (e.line) {
|
|
57
|
+
props.push(`line=${encProp(e.line)}`);
|
|
58
|
+
props.push(`col=1`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const cite = f.citation?.notificationIds?.length
|
|
62
|
+
? ` [${f.citation.notificationIds.join(", ")}]`
|
|
63
|
+
: "";
|
|
64
|
+
// Google's policy page first, because it is the authority; the explainer
|
|
65
|
+
// second, because it is what the codename actually means and how to fix it.
|
|
66
|
+
const body = [f.detail, f.citation?.policyUrl, f.citationUrl].filter(Boolean).join("\n");
|
|
67
|
+
return `::${level} ${props.join(",")}::${encData(`${f.title}${cite}\n${body}`)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The summary footer dates the policy data, so a reader can tell a stale build
|
|
71
|
+
// from a current one. It is cosmetic, and a build with an unreadable data file
|
|
72
|
+
// should still report its findings, so a failure here drops the footer rather
|
|
73
|
+
// than the report.
|
|
74
|
+
function readPolicyMeta() {
|
|
75
|
+
try {
|
|
76
|
+
const p = JSON.parse(readFileSync(join(HERE, "..", "data", "policy.json"), "utf8"));
|
|
77
|
+
return { datasetPulledAt: p.datasetPulledAt, enforcedFrom: p.enforcedFrom };
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function summary(result, policy) {
|
|
84
|
+
const { fail, warn, info } = result.counts;
|
|
85
|
+
const lines = [];
|
|
86
|
+
lines.push(`## webstore-lint`);
|
|
87
|
+
lines.push("");
|
|
88
|
+
if (result.manifestError) {
|
|
89
|
+
lines.push(`**The extension could not be read.** ${result.manifestError}`);
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(
|
|
92
|
+
"Nothing was checked, so this is not a clean result. Point the action at the " +
|
|
93
|
+
"directory holding manifest.json with the `path` input.",
|
|
94
|
+
);
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
lines.push(
|
|
98
|
+
`**${fail} failing, ${warn} needing your judgement, ${info} informational** in \`${result.root}\``,
|
|
99
|
+
);
|
|
100
|
+
lines.push("");
|
|
101
|
+
if (!result.findings.length) {
|
|
102
|
+
lines.push("No findings. This reads your package only. Your store listing, privacy policy page");
|
|
103
|
+
lines.push("and screenshots are where several policies are actually satisfied, and no local");
|
|
104
|
+
lines.push("tool can see them, so a clean run is not a promise of approval.");
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
const MARK = { fail: "FAIL", warn: "WARN", info: "INFO" };
|
|
108
|
+
lines.push("| | Finding | Where | Policy |");
|
|
109
|
+
lines.push("|---|---|---|---|");
|
|
110
|
+
for (const f of result.findings) {
|
|
111
|
+
const where = (f.evidence || [])
|
|
112
|
+
.slice(0, 3)
|
|
113
|
+
.map((e) => (e.file ? `\`${e.file}${e.line ? ":" + e.line : ""}\`` : ""))
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join(" ");
|
|
116
|
+
const more = (f.evidence || []).length > 3 ? ` +${f.evidence.length - 3}` : "";
|
|
117
|
+
// The codename links to Google, who decide; "explained" links to our page
|
|
118
|
+
// for that exact codename. Both, never one instead of the other - replacing
|
|
119
|
+
// the authority with ourselves would be the wrong trade in a compliance tool.
|
|
120
|
+
const cite = f.citation?.notificationIds?.length
|
|
121
|
+
? `[${f.citation.notificationIds.join(", ")}](${f.citation.policyUrl})` +
|
|
122
|
+
(f.citationUrl ? ` - [explained](${f.citationUrl})` : "")
|
|
123
|
+
: "";
|
|
124
|
+
// A pipe inside a cell would start a new column and shear the table.
|
|
125
|
+
const cell = (s) => String(s).replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
126
|
+
lines.push(`| ${MARK[f.severity]} | ${cell(f.title)} | ${where}${more} | ${cite} |`);
|
|
127
|
+
}
|
|
128
|
+
lines.push("");
|
|
129
|
+
if (policy?.enforcedFrom) {
|
|
130
|
+
lines.push(`Policy data pulled ${policy.datasetPulledAt}, ${policy.enforcedFrom} updates included.`);
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("Run it yourself: `npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension`");
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// EXIT CODE. `fail-on` governs FINDINGS, and an unreadable extension is not a
|
|
138
|
+
// finding - it is the tool not having done its job. So it fails whatever
|
|
139
|
+
// `fail-on` says. The alternative is that somebody types the path wrong, sets
|
|
140
|
+
// `fail-on: never` for advisory annotations, and gets a green check on an
|
|
141
|
+
// extension that was never opened. This package's own rule, from bin/: a tool
|
|
142
|
+
// that says "clean" about a question it never answered is worse than one that
|
|
143
|
+
// refuses.
|
|
144
|
+
export function exitCodeFor(result, failOn) {
|
|
145
|
+
if (result.manifestError) return 1;
|
|
146
|
+
if (failOn === "never") return 0;
|
|
147
|
+
if (failOn === "warn") return result.counts.fail + result.counts.warn ? 1 : 0;
|
|
148
|
+
return result.counts.fail ? 1 : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function run({ env = process.env, out = console.log, err = console.error } = {}) {
|
|
152
|
+
const failOn = (env.WSL_FAIL_ON || "fail").trim();
|
|
153
|
+
// Same reasoning as the CLI's unknown-flag check, applied at a new call site:
|
|
154
|
+
// a value we do not recognise must not quietly become the default. "failure",
|
|
155
|
+
// "true" and "all" are all things a person would plausibly type here, and each
|
|
156
|
+
// of them silently meaning "fail" is a build that does not block when its
|
|
157
|
+
// author believes it does.
|
|
158
|
+
if (!FAIL_ON.has(failOn)) {
|
|
159
|
+
err(`fail-on: ${JSON.stringify(failOn)} is not one of ${[...FAIL_ON].join(", ")}.`);
|
|
160
|
+
err(`Nothing was checked. Fix the value in your workflow file.`);
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
const annotate = (env.WSL_ANNOTATIONS || "true").trim() !== "false";
|
|
164
|
+
|
|
165
|
+
const args = [cliPath(env), env.WSL_PATH || ".", "--json"];
|
|
166
|
+
const url = (env.WSL_PRIVACY_POLICY || "").trim();
|
|
167
|
+
if (url) args.push("--privacy-policy", url);
|
|
168
|
+
|
|
169
|
+
const r = spawnSync(process.execPath, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
170
|
+
|
|
171
|
+
// THE LOAD-BEARING CHECK. "No findings" and "the linter never produced a
|
|
172
|
+
// report" print the same empty list if you are careless, and the second one
|
|
173
|
+
// must never come out green. So the report is only believed when it parses and
|
|
174
|
+
// carries the counts object the linter always writes; anything else is the
|
|
175
|
+
// scan having failed, and it is reported as that rather than as a clean run.
|
|
176
|
+
let result = null;
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(r.stdout);
|
|
179
|
+
if (parsed && typeof parsed === "object" && parsed.counts && typeof parsed.counts.fail === "number") {
|
|
180
|
+
result = parsed;
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
/* handled below */
|
|
184
|
+
}
|
|
185
|
+
if (!result) {
|
|
186
|
+
err("webstore-lint did not produce a report, so nothing was checked.");
|
|
187
|
+
if (r.error) err(String(r.error.message || r.error));
|
|
188
|
+
if (r.stderr) err(r.stderr.trim());
|
|
189
|
+
if (r.stdout) err(`unparsed output: ${r.stdout.slice(0, 2000)}`);
|
|
190
|
+
err(`exit code was ${r.status}`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (annotate) {
|
|
195
|
+
for (const f of result.findings) {
|
|
196
|
+
const ev = (f.evidence || []).length ? f.evidence.slice(0, 10) : [null];
|
|
197
|
+
for (const e of ev) out(annotation(f, e));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const md = summary(result, readPolicyMeta());
|
|
202
|
+
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, md + "\n");
|
|
203
|
+
else out(md);
|
|
204
|
+
|
|
205
|
+
if (env.GITHUB_OUTPUT) {
|
|
206
|
+
const { fail, warn, info } = result.counts;
|
|
207
|
+
appendFileSync(
|
|
208
|
+
env.GITHUB_OUTPUT,
|
|
209
|
+
`fail=${fail}\nwarn=${warn}\ninfo=${info}\nunreadable=${result.manifestError ? "true" : "false"}\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const code = exitCodeFor(result, failOn);
|
|
214
|
+
if (code === 1 && result.manifestError) {
|
|
215
|
+
err(`The extension could not be read, so nothing was checked: ${result.manifestError}`);
|
|
216
|
+
}
|
|
217
|
+
return code;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Only act when run as a program. Imported by the test suite, where exiting the
|
|
221
|
+
// process would take the suite with it.
|
|
222
|
+
if (process.argv[1] && process.argv[1].endsWith("report.mjs")) process.exit(run());
|