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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cli.js +102 -0
- package/dist/data/chrome-permission-map.js +54 -0
- package/dist/engine/analyze.js +87 -0
- package/dist/engine/file-discovery.js +122 -0
- package/dist/engine/manifest-refs.js +95 -0
- package/dist/engine/manifest.js +41 -0
- package/dist/engine/model.js +2 -0
- package/dist/engine/source-lines.js +22 -0
- package/dist/engine/verdict.js +25 -0
- package/dist/parse/extract-api-calls.js +189 -0
- package/dist/parse/parse-script.js +50 -0
- package/dist/reporters/human.js +73 -0
- package/dist/reporters/json.js +3 -0
- package/dist/rules/files.js +71 -0
- package/dist/rules/index.js +10 -0
- package/dist/rules/mv3.js +121 -0
- package/dist/rules/permissions.js +166 -0
- package/package.json +61 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { walk } from "./parse-script.js";
|
|
2
|
+
const ROOTS = new Set(["chrome", "browser"]);
|
|
3
|
+
const GLOBALS = new Set(["globalThis", "window", "self"]);
|
|
4
|
+
const SENSITIVE_PROPS = new Set(["url", "pendingUrl", "title", "favIconUrl"]);
|
|
5
|
+
// Match identifiers that name a tab: `tab`, `tabs`, `activeTab`, `currentTab`,
|
|
6
|
+
// `tabs[0]` (via recursion) — but NOT `table`, `stable`, `dataTable`, `crontab`.
|
|
7
|
+
// Requires `tab`/`tabs` as a trailing word or camelCase segment.
|
|
8
|
+
const TAB_NAME = /(?:^|[^a-zA-Z])[Tt]abs?$|[a-z][Tt]abs?$/;
|
|
9
|
+
// The chrome.tabs.onUpdated callback's changeInfo carries `url`/`title` only when
|
|
10
|
+
// `tabs` is granted, but its param is conventionally named changeInfo/changes
|
|
11
|
+
// (not tab-like). A sensitive read off these names is a real Tab read. (Reads of
|
|
12
|
+
// non-sensitive props like `info.status` never reach this check.)
|
|
13
|
+
const TAB_CONTEXT = new Set(["changeInfo", "changes"]);
|
|
14
|
+
const MEMBER = new Set(["MemberExpression", "OptionalMemberExpression"]);
|
|
15
|
+
function flatten(node) {
|
|
16
|
+
const accesses = [];
|
|
17
|
+
let cur = node;
|
|
18
|
+
while (cur && MEMBER.has(cur.type)) {
|
|
19
|
+
accesses.unshift({ computed: cur.computed, property: cur.property });
|
|
20
|
+
cur = cur.object;
|
|
21
|
+
}
|
|
22
|
+
return { base: cur, accesses };
|
|
23
|
+
}
|
|
24
|
+
/** Resolve a member chain to a chrome/browser path, or null if not rooted there. */
|
|
25
|
+
function resolveChain(node, aliases) {
|
|
26
|
+
const { base, accesses } = flatten(node);
|
|
27
|
+
if (!base || base.type !== "Identifier")
|
|
28
|
+
return null;
|
|
29
|
+
let root;
|
|
30
|
+
const path = [];
|
|
31
|
+
if (ROOTS.has(base.name)) {
|
|
32
|
+
root = base.name;
|
|
33
|
+
}
|
|
34
|
+
else if (aliases.has(base.name)) {
|
|
35
|
+
const a = aliases.get(base.name);
|
|
36
|
+
root = a.root;
|
|
37
|
+
path.push(...a.path);
|
|
38
|
+
}
|
|
39
|
+
else if (GLOBALS.has(base.name) &&
|
|
40
|
+
accesses[0] &&
|
|
41
|
+
!accesses[0].computed &&
|
|
42
|
+
accesses[0].property?.type === "Identifier" &&
|
|
43
|
+
ROOTS.has(accesses[0].property.name)) {
|
|
44
|
+
root = accesses.shift().property.name;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
for (const acc of accesses) {
|
|
50
|
+
const p = acc.property;
|
|
51
|
+
if (acc.computed) {
|
|
52
|
+
if (p?.type === "StringLiteral")
|
|
53
|
+
path.push(p.value);
|
|
54
|
+
else
|
|
55
|
+
return { root, path, dynamic: true };
|
|
56
|
+
}
|
|
57
|
+
else if (p?.type === "Identifier") {
|
|
58
|
+
path.push(p.name);
|
|
59
|
+
}
|
|
60
|
+
else if (p?.type === "StringLiteral") {
|
|
61
|
+
path.push(p.value);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
return { root, path, dynamic: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { root, path, dynamic: false };
|
|
68
|
+
}
|
|
69
|
+
/** Static-only resolution used while building the alias table (no alias lookup). */
|
|
70
|
+
function resolveStatic(node) {
|
|
71
|
+
if (!node)
|
|
72
|
+
return null;
|
|
73
|
+
if (node.type === "Identifier" && ROOTS.has(node.name)) {
|
|
74
|
+
return { root: node.name, path: [] };
|
|
75
|
+
}
|
|
76
|
+
if (MEMBER.has(node.type)) {
|
|
77
|
+
const r = resolveChain(node, new Map());
|
|
78
|
+
if (r && !r.dynamic)
|
|
79
|
+
return { root: r.root, path: r.path };
|
|
80
|
+
}
|
|
81
|
+
// Cross-browser polyfill idioms: `chrome || browser`, `browser ?? chrome`,
|
|
82
|
+
// `typeof browser !== 'undefined' ? browser : chrome`. Alias resolves to
|
|
83
|
+
// whichever side is a chrome/browser root.
|
|
84
|
+
if (node.type === "LogicalExpression") {
|
|
85
|
+
return resolveStatic(node.left) ?? resolveStatic(node.right);
|
|
86
|
+
}
|
|
87
|
+
if (node.type === "ConditionalExpression") {
|
|
88
|
+
return resolveStatic(node.consequent) ?? resolveStatic(node.alternate);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function buildAliases(ast) {
|
|
93
|
+
const map = new Map();
|
|
94
|
+
walk(ast, (node) => {
|
|
95
|
+
if (node.type !== "VariableDeclarator" || !node.init)
|
|
96
|
+
return;
|
|
97
|
+
const base = resolveStatic(node.init);
|
|
98
|
+
if (!base)
|
|
99
|
+
return;
|
|
100
|
+
if (node.id?.type === "Identifier") {
|
|
101
|
+
map.set(node.id.name, { root: base.root, path: base.path });
|
|
102
|
+
}
|
|
103
|
+
else if (node.id?.type === "ObjectPattern") {
|
|
104
|
+
for (const prop of node.id.properties) {
|
|
105
|
+
if (prop.type === "ObjectProperty" &&
|
|
106
|
+
prop.key?.type === "Identifier" &&
|
|
107
|
+
prop.value?.type === "Identifier") {
|
|
108
|
+
map.set(prop.value.name, { root: base.root, path: [...base.path, prop.key.name] });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return map;
|
|
114
|
+
}
|
|
115
|
+
function walkParent(node, parent, visit) {
|
|
116
|
+
if (!node || typeof node !== "object")
|
|
117
|
+
return;
|
|
118
|
+
if (typeof node.type === "string")
|
|
119
|
+
visit(node, parent);
|
|
120
|
+
for (const key of Object.keys(node)) {
|
|
121
|
+
if (key === "loc")
|
|
122
|
+
continue;
|
|
123
|
+
const child = node[key];
|
|
124
|
+
if (Array.isArray(child))
|
|
125
|
+
child.forEach((c) => walkParent(c, node, visit));
|
|
126
|
+
else if (child && typeof child === "object" && typeof child.type === "string")
|
|
127
|
+
walkParent(child, node, visit);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function isTabish(obj) {
|
|
131
|
+
if (!obj)
|
|
132
|
+
return false;
|
|
133
|
+
if (obj.type === "Identifier")
|
|
134
|
+
return TAB_NAME.test(obj.name) || TAB_CONTEXT.has(obj.name);
|
|
135
|
+
if (MEMBER.has(obj.type))
|
|
136
|
+
return isTabish(obj.object);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
function isAbsoluteUrlArg(arg) {
|
|
140
|
+
return arg?.type === "StringLiteral" && /^https?:\/\//i.test(arg.value);
|
|
141
|
+
}
|
|
142
|
+
export function extractFromScripts(scripts) {
|
|
143
|
+
const apiCalls = [];
|
|
144
|
+
const sensitiveTabReads = [];
|
|
145
|
+
const dynamicApiAccess = [];
|
|
146
|
+
const hostAccessSignals = [];
|
|
147
|
+
for (const script of scripts) {
|
|
148
|
+
if (!script.ast || script.parseError)
|
|
149
|
+
continue;
|
|
150
|
+
const file = script.relPath;
|
|
151
|
+
const aliases = buildAliases(script.ast);
|
|
152
|
+
walkParent(script.ast, null, (node, parent) => {
|
|
153
|
+
if (MEMBER.has(node.type)) {
|
|
154
|
+
const line = node.loc?.start.line;
|
|
155
|
+
// Sensitive tab property read — can appear ANYWHERE in a chain, e.g.
|
|
156
|
+
// `tab.url.startsWith(...)`, so check every member node, not just the top.
|
|
157
|
+
if (!node.computed && node.property?.type === "Identifier" && SENSITIVE_PROPS.has(node.property.name) && isTabish(node.object)) {
|
|
158
|
+
sensitiveTabReads.push({ property: node.property.name, file, line });
|
|
159
|
+
}
|
|
160
|
+
// chrome/browser chain resolution only at the top of a chain (the top
|
|
161
|
+
// member carries the whole expression; resolving inner ones double-counts).
|
|
162
|
+
const isTop = !(parent && MEMBER.has(parent.type) && parent.object === node);
|
|
163
|
+
if (isTop) {
|
|
164
|
+
const r = resolveChain(node, aliases);
|
|
165
|
+
if (r) {
|
|
166
|
+
if (r.dynamic) {
|
|
167
|
+
dynamicApiAccess.push({ root: r.root, file, line });
|
|
168
|
+
}
|
|
169
|
+
else if (r.path.length >= 1) {
|
|
170
|
+
apiCalls.push({ root: r.root, namespace: r.path[0], path: r.path, file, line, column: node.loc?.start.column, confidence: "high" });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Cross-origin fetch
|
|
177
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
178
|
+
if (isAbsoluteUrlArg(node.arguments?.[0])) {
|
|
179
|
+
hostAccessSignals.push({ kind: "fetch", file, line: node.loc?.start.line });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// XMLHttpRequest construction
|
|
183
|
+
if (node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "XMLHttpRequest") {
|
|
184
|
+
hostAccessSignals.push({ kind: "xhr", file, line: node.loc?.start.line });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return { apiCalls, sensitiveTabReads, dynamicApiAccess, hostAccessSignals };
|
|
189
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
// ponytail: @babel/parser is the committed parser. oxc-parser was the planned
|
|
3
|
+
// primary but is a native binding with a churny API, and its only v0.1 edge
|
|
4
|
+
// (perf on large/minified files) is explicitly out of scope. The parseScript()
|
|
5
|
+
// seam below is the single point where oxc could later slot in.
|
|
6
|
+
function pluginsFor(relPath) {
|
|
7
|
+
const lower = relPath.toLowerCase();
|
|
8
|
+
if (lower.endsWith(".tsx"))
|
|
9
|
+
return ["typescript", "jsx"];
|
|
10
|
+
if (lower.endsWith(".ts"))
|
|
11
|
+
return ["typescript"];
|
|
12
|
+
return ["jsx"]; // .js/.mjs/.cjs/.jsx
|
|
13
|
+
}
|
|
14
|
+
/** Parse one file behind the seam. Never throws — a parse failure yields a
|
|
15
|
+
* ScriptFile with ast=null and parseError=true so other files still process. */
|
|
16
|
+
export function parseScript(file) {
|
|
17
|
+
try {
|
|
18
|
+
const ast = parse(file.content, {
|
|
19
|
+
sourceType: "unambiguous",
|
|
20
|
+
allowReturnOutsideFunction: true,
|
|
21
|
+
allowAwaitOutsideFunction: true,
|
|
22
|
+
errorRecovery: true,
|
|
23
|
+
plugins: pluginsFor(file.relPath),
|
|
24
|
+
});
|
|
25
|
+
return { ...file, ast, parseError: false };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { ...file, ast: null, parseError: true };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Depth-first walk over a babel AST. Calls visitor(node) for every node that
|
|
32
|
+
* carries a string `type`. Lazy generic traversal — no @babel/traverse, no scope. */
|
|
33
|
+
export function walk(node, visitor) {
|
|
34
|
+
if (!node || typeof node !== "object")
|
|
35
|
+
return;
|
|
36
|
+
if (typeof node.type === "string")
|
|
37
|
+
visitor(node);
|
|
38
|
+
for (const key of Object.keys(node)) {
|
|
39
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments")
|
|
40
|
+
continue;
|
|
41
|
+
const child = node[key];
|
|
42
|
+
if (Array.isArray(child)) {
|
|
43
|
+
for (const c of child)
|
|
44
|
+
walk(c, visitor);
|
|
45
|
+
}
|
|
46
|
+
else if (child && typeof child === "object" && typeof child.type === "string") {
|
|
47
|
+
walk(child, visitor);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from "../engine/verdict.js";
|
|
2
|
+
// Optional interest-signal line, shown once after a human-mode run (suppressed in
|
|
3
|
+
// JSON and under --min-severity reject). Off by default in v0.1 — enable only when
|
|
4
|
+
// WAITLIST_URL points to a real page. Behind a constant so it's easy to toggle.
|
|
5
|
+
export const SHOW_DEMAND_LINE = false;
|
|
6
|
+
export const WAITLIST_URL = "https://tabsmith-lint.dev/monitor-waitlist";
|
|
7
|
+
const VERDICT_HEADER = {
|
|
8
|
+
pass: "PASS",
|
|
9
|
+
needs_fixes: "NEEDS FIXES",
|
|
10
|
+
high_rejection_risk: "HIGH REJECTION RISK",
|
|
11
|
+
};
|
|
12
|
+
const VERDICT_WORDS = {
|
|
13
|
+
pass: "pass",
|
|
14
|
+
needs_fixes: "needs fixes",
|
|
15
|
+
high_rejection_risk: "high rejection risk",
|
|
16
|
+
};
|
|
17
|
+
export function renderHuman(report, minSeverity = "info") {
|
|
18
|
+
const chrome = report.browsers.chrome;
|
|
19
|
+
const lines = [];
|
|
20
|
+
lines.push(`tabsmith-lint v${report.version} analyzing ${report.inputPath}`);
|
|
21
|
+
const mv = report.manifestVersion !== undefined ? `Manifest V${report.manifestVersion}` : "Manifest version unknown";
|
|
22
|
+
lines.push(`${mv}, ${chrome.stats.filesScanned} files scanned, ${chrome.stats.scriptsParsed} scripts parsed`);
|
|
23
|
+
lines.push("");
|
|
24
|
+
lines.push(`CHROME: ${VERDICT_HEADER[chrome.verdict]}`);
|
|
25
|
+
lines.push("");
|
|
26
|
+
if (chrome.findings.length === 0) {
|
|
27
|
+
lines.push("No findings.");
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
for (const severity of SEVERITY_ORDER) {
|
|
31
|
+
for (const f of chrome.findings.filter((x) => x.severity === severity)) {
|
|
32
|
+
lines.push(...renderFinding(f));
|
|
33
|
+
lines.push("");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
lines.push(`Verdict: ${VERDICT_WORDS[chrome.verdict]} (${countLabel(chrome.findings)})`);
|
|
38
|
+
if (SHOW_DEMAND_LINE && minSeverity !== "reject") {
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push(`Want alerts if your published extension is removed or its review status changes? → ${WAITLIST_URL}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
function renderFinding(f) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const violation = f.chromeViolationId ? ` (${f.chromeViolationId})` : "";
|
|
47
|
+
out.push(`[${f.severity}] ${f.ruleId} ${f.title}${violation}`);
|
|
48
|
+
if (f.file) {
|
|
49
|
+
let loc = f.file;
|
|
50
|
+
if (f.line !== undefined)
|
|
51
|
+
loc += `:${f.line}`;
|
|
52
|
+
if (f.column !== undefined)
|
|
53
|
+
loc += `:${f.column}`;
|
|
54
|
+
out.push(` ${loc}`);
|
|
55
|
+
}
|
|
56
|
+
out.push(` ${f.message}`);
|
|
57
|
+
if (f.fixHint)
|
|
58
|
+
out.push(` Fix: ${f.fixHint}`);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function countLabel(findings) {
|
|
62
|
+
const counts = { reject: 0, fix: 0, info: 0 };
|
|
63
|
+
for (const f of findings)
|
|
64
|
+
counts[f.severity]++;
|
|
65
|
+
const parts = [];
|
|
66
|
+
if (counts.reject)
|
|
67
|
+
parts.push(`${counts.reject} reject`);
|
|
68
|
+
if (counts.fix)
|
|
69
|
+
parts.push(`${counts.fix} fix`);
|
|
70
|
+
if (counts.info)
|
|
71
|
+
parts.push(`${counts.info} info`);
|
|
72
|
+
return parts.length ? parts.join(", ") : "no findings";
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, statSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { findLine } from "../engine/source-lines.js";
|
|
4
|
+
import { isWithinRoot, refToRelPath } from "../engine/file-discovery.js";
|
|
5
|
+
// FUNC001 (missing file → reject) + FUNC002 (case-only mismatch → fix).
|
|
6
|
+
//
|
|
7
|
+
// We compare the manifest reference against actual readdir() entry names with
|
|
8
|
+
// case-sensitive string equality, rather than relying on existsSync(). This is
|
|
9
|
+
// deliberate: on a case-insensitive filesystem (macOS APFS default) existsSync
|
|
10
|
+
// would resolve "Popup.html" to "popup.html" and silently hide the mismatch
|
|
11
|
+
// that Chrome's case-sensitive packaging would reject.
|
|
12
|
+
function fileRefs(model) {
|
|
13
|
+
const findings = [];
|
|
14
|
+
for (const ref of model.manifestRefs) {
|
|
15
|
+
// A reference that escapes the package root is invalid packaging — treat it
|
|
16
|
+
// as missing rather than statting an out-of-package path.
|
|
17
|
+
if (!isWithinRoot(model.rootDir, ref.ref)) {
|
|
18
|
+
findings.push({
|
|
19
|
+
ruleId: "FUNC001",
|
|
20
|
+
severity: "reject",
|
|
21
|
+
confidence: "high",
|
|
22
|
+
title: `Manifest reference points outside the package: "${ref.ref}"`,
|
|
23
|
+
message: `"${ref.ref}" (${ref.source}) resolves outside the extension directory and cannot be packaged.`,
|
|
24
|
+
file: "manifest.json",
|
|
25
|
+
line: findLine(model.manifestRaw, ref.ref),
|
|
26
|
+
chromeViolationId: "Yellow Magnesium",
|
|
27
|
+
fixHint: `Use a path inside the extension package for "${ref.source}".`,
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const parts = refToRelPath(ref.ref).split("/").filter(Boolean);
|
|
32
|
+
const base = parts.pop();
|
|
33
|
+
const parentAbs = join(model.rootDir, ...parts);
|
|
34
|
+
let entries = [];
|
|
35
|
+
if (existsSync(parentAbs) && statSync(parentAbs).isDirectory()) {
|
|
36
|
+
entries = readdirSync(parentAbs, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
const exact = entries.find((e) => e.name === base);
|
|
39
|
+
if (exact?.isFile())
|
|
40
|
+
continue;
|
|
41
|
+
const caseMatch = entries.find((e) => e.name.toLowerCase() === base.toLowerCase())?.name;
|
|
42
|
+
const line = findLine(model.manifestRaw, ref.ref);
|
|
43
|
+
if (caseMatch) {
|
|
44
|
+
findings.push({
|
|
45
|
+
ruleId: "FUNC002",
|
|
46
|
+
severity: "fix",
|
|
47
|
+
confidence: "high",
|
|
48
|
+
title: `Case-sensitivity mismatch for "${ref.ref}"`,
|
|
49
|
+
message: `Manifest references "${ref.ref}" (${ref.source}) but the file on disk is "${caseMatch}". Chrome Web Store packaging is case-sensitive.`,
|
|
50
|
+
file: "manifest.json",
|
|
51
|
+
line,
|
|
52
|
+
fixHint: `Rename the file or update the manifest path to match exact case.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
findings.push({
|
|
57
|
+
ruleId: "FUNC001",
|
|
58
|
+
severity: "reject",
|
|
59
|
+
confidence: "high",
|
|
60
|
+
title: `Manifest-referenced file is missing: "${ref.ref}"`,
|
|
61
|
+
message: `"${ref.ref}" (${ref.source}) is referenced by the manifest but does not exist in the package.`,
|
|
62
|
+
file: "manifest.json",
|
|
63
|
+
line,
|
|
64
|
+
chromeViolationId: "Yellow Magnesium",
|
|
65
|
+
fixHint: `Add the missing file or remove the "${ref.source}" reference.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return findings;
|
|
70
|
+
}
|
|
71
|
+
export const fileRules = [fileRefs];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SEVERITY_RANK } from "../engine/verdict.js";
|
|
2
|
+
import { permissionRules } from "./permissions.js";
|
|
3
|
+
import { mv3Rules } from "./mv3.js";
|
|
4
|
+
import { fileRules } from "./files.js";
|
|
5
|
+
export const allRules = [...permissionRules, ...mv3Rules, ...fileRules];
|
|
6
|
+
/** Run every rule and return all findings, ordered reject → fix → info. */
|
|
7
|
+
export function runRules(model) {
|
|
8
|
+
const findings = allRules.flatMap((rule) => rule(model));
|
|
9
|
+
return findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
|
|
10
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { walk } from "../parse/parse-script.js";
|
|
2
|
+
import { findLineByIndex } from "../engine/source-lines.js";
|
|
3
|
+
// MV3002 (string execution: eval/new Function/string timers) ships at `fix`, not
|
|
4
|
+
// `reject`, in v0.1. The pattern is a real MV3 CSP concern, but static analysis
|
|
5
|
+
// cannot tell whether the call is reachable or runs in a sandboxed context — and
|
|
6
|
+
// it commonly appears in bundled libraries (LESS/CSS compilers, JSON-parse
|
|
7
|
+
// fallbacks) of extensions that are published and working. Flagging those as
|
|
8
|
+
// "high rejection risk" cries wolf and erodes trust. Surface it to verify, don't
|
|
9
|
+
// condemn. Single constant so it can be promoted once context analysis exists.
|
|
10
|
+
// (MV3001 remote code and MV3003 manifest_version 2 stay `reject` — deterministic.)
|
|
11
|
+
export const MV3002_SEVERITY = "fix";
|
|
12
|
+
const REMOTE_SCRIPT_RE = /<script[^>]*\bsrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi;
|
|
13
|
+
/** MV3001 — remotely hosted code (HTML remote <script src>, remote import/import()). */
|
|
14
|
+
function mv3001(model) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const html of model.htmlFiles) {
|
|
17
|
+
let m;
|
|
18
|
+
REMOTE_SCRIPT_RE.lastIndex = 0;
|
|
19
|
+
while ((m = REMOTE_SCRIPT_RE.exec(html.content)) !== null) {
|
|
20
|
+
findings.push({
|
|
21
|
+
ruleId: "MV3001",
|
|
22
|
+
severity: "reject",
|
|
23
|
+
confidence: "high",
|
|
24
|
+
title: "Remotely hosted code referenced",
|
|
25
|
+
message: `<script src="${m[1]}"> loads code from a remote URL, which Manifest V3 forbids.`,
|
|
26
|
+
file: html.relPath,
|
|
27
|
+
line: findLineByIndex(html.content, m.index),
|
|
28
|
+
chromeViolationId: "Blue Argon",
|
|
29
|
+
fixHint: "Bundle the script inside the extension package instead of loading it remotely.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const script of model.scriptFiles) {
|
|
34
|
+
if (!script.ast)
|
|
35
|
+
continue;
|
|
36
|
+
walk(script.ast, (node) => {
|
|
37
|
+
// static import of a remote URL
|
|
38
|
+
if (node.type === "ImportDeclaration" && isRemote(node.source?.value)) {
|
|
39
|
+
findings.push(remoteImport(script.relPath, node, node.source.value));
|
|
40
|
+
}
|
|
41
|
+
// dynamic import() of a remote URL
|
|
42
|
+
if (node.type === "CallExpression" && node.callee?.type === "Import" && isRemote(node.arguments?.[0]?.value)) {
|
|
43
|
+
findings.push(remoteImport(script.relPath, node, node.arguments[0].value));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
|
49
|
+
function isRemote(v) {
|
|
50
|
+
return typeof v === "string" && /^(https?:)?\/\//i.test(v);
|
|
51
|
+
}
|
|
52
|
+
function remoteImport(file, node, url) {
|
|
53
|
+
return {
|
|
54
|
+
ruleId: "MV3001",
|
|
55
|
+
severity: "reject",
|
|
56
|
+
confidence: "high",
|
|
57
|
+
title: "Remotely hosted code referenced",
|
|
58
|
+
message: `import of "${url}" loads code from a remote URL, which Manifest V3 forbids.`,
|
|
59
|
+
file,
|
|
60
|
+
line: node.loc?.start.line,
|
|
61
|
+
chromeViolationId: "Blue Argon",
|
|
62
|
+
fixHint: "Bundle the dependency inside the extension package instead of importing it remotely.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const STRING_TIMERS = new Set(["setTimeout", "setInterval"]);
|
|
66
|
+
/** MV3002 — string execution (eval, new Function, string-form timers). */
|
|
67
|
+
function mv3002(model) {
|
|
68
|
+
const findings = [];
|
|
69
|
+
for (const script of model.scriptFiles) {
|
|
70
|
+
if (!script.ast)
|
|
71
|
+
continue;
|
|
72
|
+
walk(script.ast, (node) => {
|
|
73
|
+
let what = null;
|
|
74
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "eval") {
|
|
75
|
+
what = "eval()";
|
|
76
|
+
}
|
|
77
|
+
else if (node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "Function") {
|
|
78
|
+
what = "new Function()";
|
|
79
|
+
}
|
|
80
|
+
else if (node.type === "CallExpression" &&
|
|
81
|
+
node.callee?.type === "Identifier" &&
|
|
82
|
+
STRING_TIMERS.has(node.callee.name) &&
|
|
83
|
+
isStringArg(node.arguments?.[0])) {
|
|
84
|
+
what = `${node.callee.name}("...")`;
|
|
85
|
+
}
|
|
86
|
+
if (what) {
|
|
87
|
+
findings.push({
|
|
88
|
+
ruleId: "MV3002",
|
|
89
|
+
severity: MV3002_SEVERITY,
|
|
90
|
+
confidence: "medium",
|
|
91
|
+
title: `String execution via ${what}`,
|
|
92
|
+
message: `${what} executes a string as code. Manifest V3's CSP blocks this in extension contexts unless it runs in a sandboxed page or is unreachable. Verify whether this code path actually runs.`,
|
|
93
|
+
file: script.relPath,
|
|
94
|
+
line: node.loc?.start.line,
|
|
95
|
+
column: node.loc?.start.column,
|
|
96
|
+
chromeViolationId: "Blue Argon",
|
|
97
|
+
fixHint: "Replace string execution with a real function, or confine it to a sandboxed page if it must remain.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
function isStringArg(arg) {
|
|
105
|
+
return arg?.type === "StringLiteral" || arg?.type === "TemplateLiteral";
|
|
106
|
+
}
|
|
107
|
+
/** MV3003 — Manifest V2. */
|
|
108
|
+
function mv3003(model) {
|
|
109
|
+
if (model.manifest.manifest_version !== 2)
|
|
110
|
+
return [];
|
|
111
|
+
return [{
|
|
112
|
+
ruleId: "MV3003",
|
|
113
|
+
severity: "reject",
|
|
114
|
+
confidence: "high",
|
|
115
|
+
title: "Manifest V2 is no longer accepted",
|
|
116
|
+
message: "manifest_version is 2. Chrome Web Store requires Manifest V3.",
|
|
117
|
+
file: "manifest.json",
|
|
118
|
+
fixHint: "Migrate the extension to Manifest V3.",
|
|
119
|
+
}];
|
|
120
|
+
}
|
|
121
|
+
export const mv3Rules = [mv3001, mv3002, mv3003];
|