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/src/lint.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // Run the rules and attach the policy citation to every finding.
2
+ //
3
+ // The citation is attached HERE rather than inside each rule, so a rule cannot
4
+ // invent a quote even by accident: a rule names a category id, and the words
5
+ // come from data/policy.json, which is generated from the verified dataset.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { scan } from "./scan.mjs";
11
+ import { RULES } from "./rules.mjs";
12
+ import { audit } from "./audit.mjs";
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ export const POLICY = JSON.parse(readFileSync(join(here, "..", "data", "policy.json"), "utf8"));
16
+
17
+ const EXPLAINER_BASE = "https://circadian-agent.com/research/chrome-rejections/";
18
+
19
+ const kebab = (s) =>
20
+ String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
21
+
22
+ // THE SLUG RULE IS NOT UNIFORM AND MUST NOT BE GUESSED FROM THE CODENAME.
23
+ //
24
+ // A category carrying ONE notification id lives at that id: Blue Argon is at
25
+ // /blue-argon. A category carrying SEVERAL lives at its TITLE instead: Blue
26
+ // Copper, Blue Lithium, Blue Magnesium and Blue Zinc are all at
27
+ // /prohibited-products, because publishing four pages with identical bodies is
28
+ // the thing search engines actually penalise.
29
+ //
30
+ // So kebab(notificationId) is right for 23 of the 36 ids and WRONG for the
31
+ // other 13. A dead link in a linter's output is worse than no link at all: the
32
+ // tool is then visibly wrong in front of somebody who is already having a bad
33
+ // day, which is how a tool gets muted. test/explainer.test.mjs pins every one
34
+ // of the 27 urls, and a live check confirmed all 27 resolve.
35
+ //
36
+ // This mirrors slugFor() in services/storefront/lib/chrome-rejections.ts. The
37
+ // two read the same category data so they cannot disagree about which
38
+ // categories exist, only about this rule - which is exactly what the suite
39
+ // checks, by asserting the two implementations AGREE on all 27.
40
+ export function pageUrlFor(category) {
41
+ if (!category?.notificationIds?.length) return null;
42
+ const slug =
43
+ category.notificationIds.length === 1
44
+ ? kebab(category.notificationIds[0])
45
+ : kebab(category.title);
46
+ return slug ? EXPLAINER_BASE + slug : null;
47
+ }
48
+
49
+ export function lint(root) {
50
+ const ctx = scan(root);
51
+
52
+ if (ctx.manifestError) {
53
+ return {
54
+ ...ctx,
55
+ findings: [{
56
+ rule: "manifest",
57
+ severity: "fail",
58
+ title: ctx.manifestError,
59
+ // The generic sentence is still the fallback, because it is right for a
60
+ // path that does not exist or is a file. When scan() recognised a
61
+ // framework build it has something far more useful to say, and saying
62
+ // both would bury it. Severity is unchanged: nothing was checked, and a
63
+ // green check would be a lie whichever message we print.
64
+ detail: ctx.manifestHint
65
+ || "Point webstore-lint at the directory holding manifest.json, or at the unzipped package.",
66
+ evidence: [],
67
+ citation: null,
68
+ citationUrl: null,
69
+ }],
70
+ counts: { fail: 1, warn: 0, info: 0 },
71
+ };
72
+ }
73
+
74
+ const findings = [];
75
+ for (const rule of RULES) {
76
+ let produced;
77
+ try {
78
+ produced = rule.run(ctx) || [];
79
+ } catch (e) {
80
+ // One broken rule must not take the run down and, more importantly, must
81
+ // not be mistaken for a clean result. It is reported as a finding about
82
+ // the tool itself.
83
+ findings.push({
84
+ rule: rule.id,
85
+ severity: "warn",
86
+ title: `The rule ${rule.id} crashed and checked nothing: ${e.message}`,
87
+ detail: "This is a bug in webstore-lint, not in your extension. Please report it with the manifest that triggered it.",
88
+ evidence: [],
89
+ citation: null,
90
+ citationUrl: null,
91
+ });
92
+ continue;
93
+ }
94
+ for (const f of produced) {
95
+ const citation = rule.category ? POLICY.categories[rule.category] || null : null;
96
+ findings.push({
97
+ rule: rule.id,
98
+ ...f,
99
+ citation,
100
+ // Attached HERE, next to the citation it is derived from, so every
101
+ // surface gets it from one place: the text renderer, --json, and the
102
+ // GitHub Action, which reads --json and would otherwise have to
103
+ // re-implement the slug rule and drift from it.
104
+ citationUrl: pageUrlFor(citation),
105
+ change: rule.change ? POLICY.changes2026.find((c) => c.name === rule.change) || null : null,
106
+ });
107
+ }
108
+ }
109
+
110
+ const order = { fail: 0, warn: 1, info: 2 };
111
+ findings.sort((a, b) => order[a.severity] - order[b.severity]);
112
+ const counts = { fail: 0, warn: 0, info: 0 };
113
+ for (const f of findings) counts[f.severity]++;
114
+
115
+ return { ...ctx, findings, counts };
116
+ }
117
+
118
+ // The permission ledger is a separate entry point because it answers a different
119
+ // question from the rules: not "is this a violation" but "what in your code
120
+ // requires this, and would a narrower permission have done". See src/audit.mjs.
121
+ export function auditPermissions(root) {
122
+ const ctx = scan(root);
123
+ if (ctx.manifestError) return { ...ctx, audit: null };
124
+ return { ...ctx, audit: audit(ctx) };
125
+ }
@@ -0,0 +1,218 @@
1
+ // The ONE check in this package that makes a network request, and it only runs
2
+ // when you pass --privacy-policy <url>.
3
+ //
4
+ // WHY IT EXISTS, and it is a real case rather than a guess. A developer's
5
+ // extension was rejected under Purple Lithium and the cause was mundane: the
6
+ // privacy policy URL in their listing 404'd, because the GitHub repository behind
7
+ // it was private. Chrome DevRel diagnosed it in one reply on the chromium-
8
+ // extensions group. Nothing about that needs a human to spot, and nothing about
9
+ // it is visible in the package - which is exactly why the rest of this tool
10
+ // warns about the privacy policy rather than checking it.
11
+ //
12
+ // GOOGLE NAMES THESE TRIGGERS IN ITS OWN WORDS on the udp-disclosure-policy
13
+ // category, which is why this is a policy check and not an opinion:
14
+ // "The privacy policy URL is not working."
15
+ // "The privacy policy is not accessible."
16
+ // "The privacy policy URL is not leading to privacy policy."
17
+ //
18
+ // THE DESIGN CONSTRAINT THIS MUST NOT BREAK. lint() is synchronous, offline and
19
+ // reads nothing but your files, and people run it in CI on packages they have
20
+ // not published. So this lives outside the rule set entirely: a separate async
21
+ // entry point the CLI calls only when a URL is supplied. No URL, no socket.
22
+ //
23
+ // WHAT IT DOES NOT DO, stated plainly because the rest of the tool is careful
24
+ // about this. It checks that the address answers and that the page looks like
25
+ // prose about data. It cannot judge whether your policy is ADEQUATE, and a
26
+ // reachable policy is not an approved one. Reviewers read the words.
27
+
28
+ // Hosts a Chrome reviewer's browser can never reach. Refusing them is honest
29
+ // policy advice first - a policy URL on localhost is a rejection waiting to
30
+ // happen - and it doubles as the guard that keeps this from being pointed at
31
+ // something inside a network by whoever wrote the URL.
32
+ const UNREACHABLE_HOST = [
33
+ /^localhost$/i,
34
+ /\.localhost$/i,
35
+ /^127\./,
36
+ /^0\./,
37
+ /^10\./,
38
+ /^192\.168\./,
39
+ /^172\.(1[6-9]|2\d|3[01])\./,
40
+ /^169\.254\./,
41
+ /^\[?::1\]?$/,
42
+ /^\[?f[cd][0-9a-f]{2}:/i,
43
+ /\.local$/i,
44
+ /\.internal$/i,
45
+ ];
46
+
47
+ const CATEGORY = "udp-disclosure-policy";
48
+
49
+ const f = (severity, title, detail, evidence = []) => ({
50
+ rule: "privacy-policy-url",
51
+ category: CATEGORY,
52
+ severity,
53
+ title,
54
+ detail,
55
+ evidence,
56
+ });
57
+
58
+ // Words that a page about data handling contains and a landing page or an error
59
+ // shell does not. Deliberately shallow, and the finding says so: this is the
60
+ // difference between "the address answers" and "the address answers with
61
+ // something plausibly about data", not a reading of your policy.
62
+ const DATA_WORDS = [
63
+ "privacy", "personal", "data", "information", "collect", "cookie",
64
+ "process", "retain", "share", "third party", "third-party",
65
+ ];
66
+
67
+ /**
68
+ * @returns {Promise<object[]>} findings, in the same shape lint() produces, so
69
+ * the CLI renders them through the same path. Empty means the URL is reachable
70
+ * and looks like a policy.
71
+ */
72
+ export async function checkPolicyUrl(url, { timeoutMs = 10000, fetchImpl = fetch } = {}) {
73
+ let u;
74
+ try {
75
+ u = new URL(url);
76
+ } catch {
77
+ return [f(
78
+ "fail",
79
+ "The privacy policy URL is not a URL",
80
+ 'Google lists "The privacy policy URL is not working" as a trigger for this category. ' +
81
+ "Paste the address exactly as it appears in the Privacy tab of your dashboard, including the scheme.",
82
+ [{ file: "--privacy-policy", text: url }],
83
+ )];
84
+ }
85
+
86
+ if (u.protocol !== "https:" && u.protocol !== "http:") {
87
+ return [f(
88
+ "fail",
89
+ `The privacy policy URL uses ${u.protocol} rather than https`,
90
+ "A reviewer opens this address in a browser. Only http and https are openable.",
91
+ [{ file: "--privacy-policy", text: url }],
92
+ )];
93
+ }
94
+
95
+ const host = u.hostname;
96
+ if (UNREACHABLE_HOST.some((re) => re.test(host))) {
97
+ return [f(
98
+ "fail",
99
+ `The privacy policy URL points at ${host}, which nobody outside your network can open`,
100
+ 'Google lists "The privacy policy is not accessible" as a trigger. A loopback, private or .local ' +
101
+ "address resolves on your machine and nowhere else, so the reviewer sees nothing. " +
102
+ "This check makes no request to such a host.",
103
+ [{ file: "--privacy-policy", text: url }],
104
+ )];
105
+ }
106
+
107
+ const findings = [];
108
+ if (u.protocol === "http:") {
109
+ findings.push(f(
110
+ "warn",
111
+ "The privacy policy URL is plain http",
112
+ "It will probably still load, but browsers mark it insecure and the same policy family requires " +
113
+ "user data to be transmitted with modern cryptography. Serve the policy over https.",
114
+ [{ file: "--privacy-policy", text: url }],
115
+ ));
116
+ }
117
+
118
+ const ctl = new AbortController();
119
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
120
+ let res;
121
+ try {
122
+ res = await fetchImpl(u.href, {
123
+ signal: ctl.signal,
124
+ redirect: "follow",
125
+ headers: {
126
+ // Some hosts serve a different page to something that does not look like
127
+ // a browser, and the question here is what a reviewer sees.
128
+ accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
129
+ "user-agent": "webstore-lint (privacy policy reachability check)",
130
+ },
131
+ });
132
+ } catch (e) {
133
+ clearTimeout(timer);
134
+ // COULD NOT LOOK IS NOT THE SAME FACT AS NOT THERE. A DNS failure on the
135
+ // developer's own machine, an offline CI runner or a proxy is not evidence
136
+ // about the URL, so this warns and says which it is rather than failing.
137
+ const aborted = e.name === "AbortError";
138
+ return [...findings, f(
139
+ "warn",
140
+ aborted
141
+ ? `The privacy policy URL did not answer within ${timeoutMs / 1000}s`
142
+ : `The privacy policy URL could not be reached from here: ${e.message}`,
143
+ "This is not the same as the address being broken. It may be your network, a proxy, or an offline " +
144
+ "runner. Open it yourself in a private browser window before you trust either answer. " +
145
+ "A reviewer will be doing exactly that.",
146
+ [{ file: "--privacy-policy", text: url }],
147
+ )];
148
+ }
149
+ clearTimeout(timer);
150
+
151
+ const finalUrl = res.url || u.href;
152
+ const redirected = finalUrl.replace(/\/$/, "") !== u.href.replace(/\/$/, "");
153
+
154
+ if (!res.ok) {
155
+ return [...findings, f(
156
+ "fail",
157
+ `The privacy policy URL answers ${res.status}`,
158
+ 'Google lists "The privacy policy URL is not working" as a trigger for this category, and a ' +
159
+ "404 here is one of the most common causes of an otherwise clean rejection. If the page lives " +
160
+ "in a source repository, check the repository is PUBLIC: a private repo serves 404 to everyone " +
161
+ "but you, and it looks fine while you are logged in.",
162
+ [{ file: redirected ? `${url} -> ${finalUrl}` : url, text: `HTTP ${res.status}` }],
163
+ )];
164
+ }
165
+
166
+ let body = "";
167
+ try {
168
+ body = await res.text();
169
+ } catch {
170
+ body = "";
171
+ }
172
+
173
+ // Strip tags and scripts before counting words, or a single-page app's bundle
174
+ // reads as a wall of text that mentions everything.
175
+ const text = body
176
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
177
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
178
+ .replace(/<[^>]+>/g, " ")
179
+ .replace(/\s+/g, " ")
180
+ .trim();
181
+
182
+ const hits = DATA_WORDS.filter((w) => text.toLowerCase().includes(w));
183
+
184
+ if (text.length < 400 || hits.length < 3) {
185
+ findings.push(f(
186
+ "warn",
187
+ `The privacy policy URL answers 200 but the page does not read like a privacy policy`,
188
+ 'Google lists "The privacy policy URL is not leading to privacy policy" as a trigger. This check ' +
189
+ "is shallow on purpose: it counted the visible text and looked for words a policy about data " +
190
+ `contains. It found ${text.length} characters and ${hits.length} of those words` +
191
+ `${hits.length ? ` (${hits.join(", ")})` : ""}. That can be wrong - a policy rendered by ` +
192
+ "JavaScript looks empty to any fetch, including a crawler's. Open the address in a private " +
193
+ "window with JavaScript disabled and see what a reviewer would see.",
194
+ [{ file: redirected ? `${url} -> ${finalUrl}` : url, text: `HTTP 200, ${text.length} chars of text` }],
195
+ ));
196
+ return findings;
197
+ }
198
+
199
+ if (redirected) {
200
+ findings.push(f(
201
+ "info",
202
+ "The privacy policy URL redirects",
203
+ `It ends at ${finalUrl}. That is fine, and worth knowing: put the address you want reviewed in ` +
204
+ "the dashboard field, since a redirect chain is one more thing that can break later.",
205
+ [{ file: url, text: `-> ${finalUrl}` }],
206
+ ));
207
+ }
208
+
209
+ findings.push(f(
210
+ "info",
211
+ "The privacy policy URL is reachable and reads like a policy",
212
+ `HTTP 200, ${text.length} characters of visible text, mentioning ${hits.join(", ")}. ` +
213
+ "That is reachability only. Whether the words actually describe what your extension collects is " +
214
+ "what a reviewer reads it for, and this tool cannot judge it.",
215
+ [{ file: finalUrl, text: `HTTP 200, ${text.length} chars of text` }],
216
+ ));
217
+ return findings;
218
+ }