tabsmith-lint 0.1.0

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.
@@ -0,0 +1,166 @@
1
+ import { chromePermissionMap, namespaceToPermission, permissionsForNamespace, isBroadHost } from "../data/chrome-permission-map.js";
2
+ import { findLine } from "../engine/source-lines.js";
3
+ // KTD 3: PERM001 ships at `fix`, NEVER `reject`, in v0.1. The flagship rule's
4
+ // false-positive rate is make-or-break ("trust is the product"). Promote to
5
+ // "reject" only after validating against a hand-labeled corpus (deferred).
6
+ // This is the single config point — change here, nowhere else.
7
+ export const PERM001_SEVERITY = "fix";
8
+ // Manifests are loosely typed (only manifest_version is validated on load), so a
9
+ // well-formed-JSON manifest can carry a non-array where an array is expected.
10
+ // Coerce defensively — never spread an arbitrary value.
11
+ const asArray = (x) => (Array.isArray(x) ? x : []);
12
+ function declaredPermissions(m) {
13
+ return [...asArray(m.permissions), ...asArray(m.optional_permissions)];
14
+ }
15
+ function hostPatterns(m) {
16
+ const out = [...asArray(m.host_permissions)];
17
+ const cs = m.content_scripts;
18
+ if (Array.isArray(cs)) {
19
+ for (const entry of cs)
20
+ out.push(...asArray(entry?.matches));
21
+ }
22
+ return out;
23
+ }
24
+ function hasBroadHost(m) {
25
+ return hostPatterns(m).some(isBroadHost);
26
+ }
27
+ function hasGestureEntryPoint(m, declared) {
28
+ const mm = m;
29
+ return Boolean(mm.action || mm.browser_action || mm.commands || mm.context_menus) || declared.includes("contextMenus");
30
+ }
31
+ // Some permissions are exercised declaratively via the manifest rather than by a
32
+ // JS API call. declarativeNetRequest is the common case: an extension can ship
33
+ // static rulesets (declarative_net_request.rule_resources) and never touch the
34
+ // chrome.declarativeNetRequest namespace in code. Counting only code usage would
35
+ // produce a false "unused" finding (trust is the product).
36
+ function isUsedViaManifest(perm, m) {
37
+ if (perm === "declarativeNetRequest")
38
+ return Boolean(m.declarative_net_request);
39
+ return false;
40
+ }
41
+ /** PERM001 — declared permission appears unused. */
42
+ function perm001(model) {
43
+ const { manifest, apiCalls, dynamicApiAccess } = model;
44
+ const declared = declaredPermissions(manifest);
45
+ const broad = hasBroadHost(manifest);
46
+ const dynamicConfidence = dynamicApiAccess.length > 0 ? "low" : "high";
47
+ const findings = [];
48
+ for (const perm of declared) {
49
+ const mapping = chromePermissionMap[perm];
50
+ if (!mapping)
51
+ continue; // unknown permission → no claim (avoid false positives)
52
+ if (mapping.namespace) {
53
+ const used = apiCalls.some((c) => c.namespace === mapping.namespace) || isUsedViaManifest(perm, manifest);
54
+ if (!used) {
55
+ findings.push(unusedFinding(model, perm, dynamicConfidence, `No chrome.${mapping.namespace}/browser.${mapping.namespace} usage found.`));
56
+ }
57
+ }
58
+ else if (mapping.special === "sensitiveTabProperties") {
59
+ // `tabs` is "used" only via sensitive Tab property reads. When broad host
60
+ // is present, redundancy is owned by PERM004 — don't double-flag here.
61
+ if (!broad && model.sensitiveTabReads.length === 0) {
62
+ findings.push(unusedFinding(model, perm, dynamicConfidence, `The "tabs" permission only grants sensitive Tab properties (url, title, ...); no such reads were found.`));
63
+ }
64
+ }
65
+ else if (mapping.special === "temporaryHostAccess") {
66
+ // `activeTab` is plausibly used only behind a user-gesture entry point.
67
+ if (!hasGestureEntryPoint(manifest, declared)) {
68
+ findings.push(unusedFinding(model, perm, "medium", `"activeTab" has no user-gesture entry point (action/commands/contextMenus) to trigger it.`));
69
+ }
70
+ }
71
+ }
72
+ return findings;
73
+ }
74
+ function unusedFinding(model, perm, confidence, message) {
75
+ return {
76
+ ruleId: "PERM001",
77
+ severity: PERM001_SEVERITY,
78
+ confidence,
79
+ title: `Permission "${perm}" appears unused`,
80
+ message,
81
+ file: "manifest.json",
82
+ line: findLine(model.manifestRaw, `"${perm}"`),
83
+ chromeViolationId: "Purple Potassium",
84
+ fixHint: `Remove "${perm}" from permissions unless it is required by code not included in this package.`,
85
+ };
86
+ }
87
+ /** PERM002 — API used but required permission missing. */
88
+ function perm002(model) {
89
+ const declared = new Set(declaredPermissions(model.manifest));
90
+ const findings = [];
91
+ const reported = new Set();
92
+ for (const call of model.apiCalls) {
93
+ if (call.confidence !== "high")
94
+ continue;
95
+ const perm = namespaceToPermission[call.namespace];
96
+ if (!perm || reported.has(perm))
97
+ continue;
98
+ // Any aliased permission string for this namespace satisfies the requirement
99
+ // (e.g. declarativeNetRequestWithHostAccess grants declarativeNetRequest).
100
+ if (permissionsForNamespace(call.namespace).some((p) => declared.has(p)))
101
+ continue;
102
+ reported.add(perm);
103
+ findings.push({
104
+ ruleId: "PERM002",
105
+ severity: "reject",
106
+ confidence: "high",
107
+ title: `API used without required permission "${perm}"`,
108
+ message: `Code calls ${call.root}.${call.namespace}.* but "${perm}" is not declared in permissions or optional_permissions.`,
109
+ file: call.file,
110
+ line: call.line,
111
+ column: call.column,
112
+ fixHint: `Add "${perm}" to the manifest "permissions" array.`,
113
+ });
114
+ }
115
+ return findings;
116
+ }
117
+ /** PERM003 — activeTab redundant with broad host permissions. */
118
+ function perm003(model) {
119
+ const declared = declaredPermissions(model.manifest);
120
+ if (!declared.includes("activeTab") || !hasBroadHost(model.manifest))
121
+ return [];
122
+ return [{
123
+ ruleId: "PERM003",
124
+ severity: "fix",
125
+ confidence: "high",
126
+ title: `"activeTab" is redundant with broad host permissions`,
127
+ message: `Broad host access already covers what activeTab grants on demand.`,
128
+ file: "manifest.json",
129
+ line: findLine(model.manifestRaw, `"activeTab"`),
130
+ fixHint: `Remove "activeTab" if broad host access is required, or narrow host access and keep "activeTab" for user-gesture access.`,
131
+ }];
132
+ }
133
+ /** PERM004 — tabs redundant with broad host permissions. */
134
+ function perm004(model) {
135
+ const declared = declaredPermissions(model.manifest);
136
+ if (!declared.includes("tabs") || !hasBroadHost(model.manifest))
137
+ return [];
138
+ return [{
139
+ ruleId: "PERM004",
140
+ severity: "fix",
141
+ confidence: "high",
142
+ title: `"tabs" is redundant with broad host permissions`,
143
+ message: `Broad host access already exposes the sensitive Tab data that the "tabs" permission grants.`,
144
+ file: "manifest.json",
145
+ line: findLine(model.manifestRaw, `"tabs"`),
146
+ fixHint: `Remove "tabs" unless the extension must read sensitive tab data outside declared host patterns.`,
147
+ }];
148
+ }
149
+ /** PERM005 — broad host access requested. */
150
+ function perm005(model) {
151
+ const patterns = hostPatterns(model.manifest);
152
+ const broad = patterns.filter(isBroadHost);
153
+ if (broad.length === 0)
154
+ return [];
155
+ return [{
156
+ ruleId: "PERM005",
157
+ severity: "fix",
158
+ confidence: "high",
159
+ title: `Broad host access requested`,
160
+ message: `Broad host patterns requested: ${[...new Set(broad)].join(", ")}.`,
161
+ file: "manifest.json",
162
+ line: findLine(model.manifestRaw, broad[0]),
163
+ fixHint: `Replace broad patterns with the narrowest required host patterns.`,
164
+ }];
165
+ }
166
+ export const permissionRules = [perm001, perm002, perm003, perm004, perm005];
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "tabsmith-lint",
3
+ "version": "0.1.0",
4
+ "description": "Pre-submission compliance linter for Manifest V3 Chrome extensions: flags likely Chrome Web Store rejection risks (unused/excessive permissions, MV3 violations, broken packaging) before you submit.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tabsmith-lint": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "keywords": [
13
+ "chrome-extension",
14
+ "manifest-v3",
15
+ "mv3",
16
+ "linter",
17
+ "chrome-web-store",
18
+ "chrome-web-store-rejection",
19
+ "purple-potassium",
20
+ "permissions",
21
+ "unused-permissions",
22
+ "extension-publishing",
23
+ "static-analysis",
24
+ "cli"
25
+ ],
26
+ "homepage": "https://github.com/rsub122/tabsmith-lint#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/rsub122/tabsmith-lint/issues"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/rsub122/tabsmith-lint.git"
33
+ },
34
+ "author": "rsub122",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "prepublishOnly": "npm run build",
41
+ "test": "vitest run",
42
+ "validate": "node scripts/validate-corpus.mjs",
43
+ "validate:releases": "node scripts/validate-corpus.mjs --releases",
44
+ "score": "node scripts/score-accuracy.mjs",
45
+ "score:releases": "node scripts/score-accuracy.mjs corpus/labels-releases.json",
46
+ "demo": "node scripts/demo.mjs",
47
+ "lint:self": "node dist/cli.js"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "license": "MIT",
53
+ "dependencies": {
54
+ "@babel/parser": "^7.24.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^20.12.0",
58
+ "typescript": "^5.4.0",
59
+ "vitest": "^1.6.0"
60
+ }
61
+ }