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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rsub122
|
|
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,90 @@
|
|
|
1
|
+
# tabsmith-lint
|
|
2
|
+
|
|
3
|
+
A pre-submission compliance linter for browser extensions. v0.1 analyzes an unpacked
|
|
4
|
+
Manifest V3 Chrome extension directory and reports likely Chrome Web Store rejection
|
|
5
|
+
risks **before** you submit — unused/excessive permissions, missing permissions, MV3
|
|
6
|
+
violations, and broken file references — with a clear verdict and actionable fixes.
|
|
7
|
+
|
|
8
|
+
**Catch the "Purple Potassium" permission rejection before you submit.** The most
|
|
9
|
+
common Chrome Web Store rejection is excessive/unused permissions — declaring `tabs`,
|
|
10
|
+
`bookmarks`, `cookies`, or `<all_urls>` your code never actually uses. tabsmith-lint
|
|
11
|
+
statically checks your manifest against your code and maps findings to the violations
|
|
12
|
+
Chrome reviewers flag:
|
|
13
|
+
|
|
14
|
+
| Violation ID | What it means | tabsmith rules |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Purple Potassium | Excessive / unused permissions | PERM001, PERM003, PERM004, PERM005 |
|
|
17
|
+
| Blue Argon | Remotely hosted code / string execution | MV3001, MV3002 |
|
|
18
|
+
| Yellow Magnesium | Functionality / broken packaging | FUNC001, FUNC002 |
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx tabsmith-lint ./my-extension
|
|
22
|
+
npx tabsmith-lint ./my-extension --format json
|
|
23
|
+
npx tabsmith-lint ./my-extension --min-severity reject
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Exit codes: `0` pass · `1` needs fixes · `2` high rejection risk · `3` tool error.
|
|
27
|
+
|
|
28
|
+
> **Status:** the package is publish-ready (verified via `npm pack` + a real install)
|
|
29
|
+
> but **not yet on npm**, so `npx tabsmith-lint` resolves only after the first
|
|
30
|
+
> `npm publish`. Until then, run it from a clone: `npm install && npm run build &&
|
|
31
|
+
> node dist/cli.js ./my-extension`.
|
|
32
|
+
|
|
33
|
+
## Develop
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install
|
|
37
|
+
npm test # vitest: unit + e2e (one fixture per rule)
|
|
38
|
+
npm run build # tsc → dist/
|
|
39
|
+
node dist/cli.js ./my-extension
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Validate against real extensions
|
|
43
|
+
|
|
44
|
+
Beyond the unit/e2e fixtures, the linter is validated on **real** extensions —
|
|
45
|
+
both Google's clean MV3 samples and real published, **built/minified** OSS
|
|
46
|
+
extensions (uBlock Origin, Dark Reader, Stylus, ...). All fetched on demand — see
|
|
47
|
+
`corpus/`:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm run build # required first — the scripts use dist/
|
|
51
|
+
npm run validate # robustness: ~100 clean extensions, gate on 0 crashes
|
|
52
|
+
npm run score # accuracy: PERM001 false-positive rate vs ground truth
|
|
53
|
+
npm run validate:releases # robustness on real minified extensions
|
|
54
|
+
npm run score:releases # accuracy on the messy corpus
|
|
55
|
+
npm run demo # print reports for recognizable real extensions
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`npm run score` gates promoting PERM001 to `reject`: under 5% false positives.
|
|
59
|
+
Across **28 hand-labeled extensions (22 samples + 6 minified releases): 0% false
|
|
60
|
+
positives, 0 false negatives, all verdicts matched** — independently audited.
|
|
61
|
+
Building this corpus caught and fixed **six** real false positives (plus a
|
|
62
|
+
prototype-safety crash) the synthetic fixtures missed — see `corpus/README.md`. CI
|
|
63
|
+
runs the suite and all validation gates on every push (`.github/workflows/ci.yml`).
|
|
64
|
+
|
|
65
|
+
## PERM001 accuracy gate
|
|
66
|
+
|
|
67
|
+
PERM001 (declared-but-unused permission) is the flagship rule, and its
|
|
68
|
+
false-positive rate is make-or-break — a wrong "remove this permission" suggestion
|
|
69
|
+
destroys trust. In v0.1 it ships at **`fix` severity, never `reject`** (the single
|
|
70
|
+
constant `PERM001_SEVERITY` in `src/rules/permissions.ts`). Promoting it to `reject`
|
|
71
|
+
is gated on validating under 5% false positives against a hand-labeled corpus of
|
|
72
|
+
20–30 real extensions, where the label is "permission actually used by
|
|
73
|
+
implementation" — not "extension passed review". That corpus is deferred; until it
|
|
74
|
+
exists, leave the severity at `fix`.
|
|
75
|
+
|
|
76
|
+
## Status
|
|
77
|
+
|
|
78
|
+
v0.1. Chrome-only, directory input, 10 rules (PERM001-005, MV3001-003, FUNC001-002).
|
|
79
|
+
|
|
80
|
+
Firefox/Edge, ZIP/CRX input, SARIF, and a GitHub Action are on the roadmap.
|
|
81
|
+
|
|
82
|
+
Two rules are intentionally conservative in v0.1 (trust is the product): **PERM001**
|
|
83
|
+
(unused permission) and **MV3002** (string execution: `eval`/`new Function`) both ship
|
|
84
|
+
at `fix` severity, not `reject` — a static linter can't always tell a real problem from
|
|
85
|
+
a sandboxed or unreachable one, so they surface for you to verify rather than condemn.
|
|
86
|
+
Each is gated behind a single severity constant for later promotion.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { analyze } from "./engine/analyze.js";
|
|
6
|
+
import { ManifestError } from "./engine/manifest.js";
|
|
7
|
+
import { verdictExitCode } from "./engine/verdict.js";
|
|
8
|
+
import { renderHuman } from "./reporters/human.js";
|
|
9
|
+
import { renderJson } from "./reporters/json.js";
|
|
10
|
+
const USAGE = "Usage: tabsmith-lint <extension-dir> [--format human|json] [--browser chrome] [--min-severity reject|fix|info]";
|
|
11
|
+
function version() {
|
|
12
|
+
try {
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
return JSON.parse(readFileSync(join(here, "../package.json"), "utf8")).version ?? "0.0.0";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "0.0.0";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function helpText() {
|
|
21
|
+
return `tabsmith-lint v${version()} - pre-submission compliance linter for Manifest V3 Chrome extensions
|
|
22
|
+
|
|
23
|
+
Point it at an unpacked extension directory and it flags likely Chrome Web Store
|
|
24
|
+
rejection risks before you submit:
|
|
25
|
+
- unused / excessive permissions (the "Purple Potassium" rejection)
|
|
26
|
+
- Manifest V3 violations: remotely hosted code, string execution, Manifest V2
|
|
27
|
+
- broken packaging: missing or wrong-case file references
|
|
28
|
+
|
|
29
|
+
${USAGE}
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--format human|json output format (default: human)
|
|
33
|
+
--browser chrome target browser (only chrome in v0.1)
|
|
34
|
+
--min-severity reject|fix|info minimum severity to report (default: info)
|
|
35
|
+
-v, --version print version
|
|
36
|
+
-h, --help show this help
|
|
37
|
+
|
|
38
|
+
Exit codes: 0 pass | 1 needs fixes | 2 high rejection risk | 3 tool error
|
|
39
|
+
Docs: https://github.com/rsub122/tabsmith-lint`;
|
|
40
|
+
}
|
|
41
|
+
export function run(argv) {
|
|
42
|
+
let dir;
|
|
43
|
+
let format = "human";
|
|
44
|
+
let browser = "chrome";
|
|
45
|
+
let minSeverity = "info";
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const arg = argv[i];
|
|
48
|
+
if (arg === "--format")
|
|
49
|
+
format = argv[++i];
|
|
50
|
+
else if (arg === "--browser")
|
|
51
|
+
browser = argv[++i];
|
|
52
|
+
else if (arg === "--min-severity")
|
|
53
|
+
minSeverity = argv[++i];
|
|
54
|
+
else if (arg === "-h" || arg === "--help")
|
|
55
|
+
return { exitCode: 0, stdout: helpText() };
|
|
56
|
+
else if (arg === "-v" || arg === "--version")
|
|
57
|
+
return { exitCode: 0, stdout: version() };
|
|
58
|
+
else if (arg.startsWith("--"))
|
|
59
|
+
return { exitCode: 3, stderr: `Unknown option: ${arg}\n${USAGE}` };
|
|
60
|
+
else if (dir === undefined)
|
|
61
|
+
dir = arg;
|
|
62
|
+
else
|
|
63
|
+
return { exitCode: 3, stderr: `Unexpected argument: ${arg}\n${USAGE}` };
|
|
64
|
+
}
|
|
65
|
+
if (!dir)
|
|
66
|
+
return { exitCode: 3, stderr: `No extension directory provided.\n${USAGE}` };
|
|
67
|
+
if (browser !== "chrome")
|
|
68
|
+
return { exitCode: 3, stderr: `Only --browser chrome is supported in v0.1 (got "${browser}").` };
|
|
69
|
+
if (!["human", "json"].includes(format))
|
|
70
|
+
return { exitCode: 3, stderr: `Invalid --format "${format}" (expected human|json).` };
|
|
71
|
+
if (!["reject", "fix", "info"].includes(minSeverity))
|
|
72
|
+
return { exitCode: 3, stderr: `Invalid --min-severity "${minSeverity}" (expected reject|fix|info).` };
|
|
73
|
+
let report;
|
|
74
|
+
try {
|
|
75
|
+
report = analyze(dir, { version: version(), minSeverity });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
const msg = e instanceof ManifestError ? e.message : e.message;
|
|
79
|
+
return { exitCode: 3, stderr: `error: ${msg}` };
|
|
80
|
+
}
|
|
81
|
+
const stdout = format === "json" ? renderJson(report) : renderHuman(report, minSeverity);
|
|
82
|
+
return { exitCode: verdictExitCode(report.browsers.chrome.verdict), stdout };
|
|
83
|
+
}
|
|
84
|
+
// Run as a CLI only when invoked directly (not when imported by tests). npm/npx
|
|
85
|
+
// install the bin as a SYMLINK (node_modules/.bin/tabsmith-lint -> dist/cli.js),
|
|
86
|
+
// so process.argv[1] is the symlink path while import.meta.url is the real file —
|
|
87
|
+
// compare realpaths, or the published binary silently does nothing.
|
|
88
|
+
let invokedDirectly = false;
|
|
89
|
+
try {
|
|
90
|
+
invokedDirectly = !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
invokedDirectly = false;
|
|
94
|
+
}
|
|
95
|
+
if (invokedDirectly) {
|
|
96
|
+
const result = run(process.argv.slice(2));
|
|
97
|
+
if (result.stdout)
|
|
98
|
+
console.log(result.stdout);
|
|
99
|
+
if (result.stderr)
|
|
100
|
+
console.error(result.stderr);
|
|
101
|
+
process.exit(result.exitCode);
|
|
102
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// High-frequency Chrome permission → API namespace map (PRD 6.6). Not exhaustive
|
|
2
|
+
// by design; unknown permissions are simply not mapped (no false unused finding).
|
|
3
|
+
// Null-prototype throughout: namespace/permission keys come from parsed code, so
|
|
4
|
+
// a lookup like map["constructor"] must not resolve to an inherited Object member.
|
|
5
|
+
export const chromePermissionMap = Object.assign(Object.create(null), {
|
|
6
|
+
storage: { namespace: "storage" },
|
|
7
|
+
scripting: { namespace: "scripting" },
|
|
8
|
+
cookies: { namespace: "cookies", requiresHostPermission: true },
|
|
9
|
+
bookmarks: { namespace: "bookmarks" },
|
|
10
|
+
alarms: { namespace: "alarms" },
|
|
11
|
+
notifications: { namespace: "notifications" },
|
|
12
|
+
contextMenus: { namespace: "contextMenus" },
|
|
13
|
+
webRequest: { namespace: "webRequest", requiresHostPermission: true },
|
|
14
|
+
declarativeNetRequest: { namespace: "declarativeNetRequest" },
|
|
15
|
+
downloads: { namespace: "downloads" },
|
|
16
|
+
history: { namespace: "history" },
|
|
17
|
+
identity: { namespace: "identity" },
|
|
18
|
+
management: { namespace: "management" },
|
|
19
|
+
tabs: { special: "sensitiveTabProperties" },
|
|
20
|
+
activeTab: { special: "temporaryHostAccess" },
|
|
21
|
+
});
|
|
22
|
+
/** namespace → permission required to use it (PERM002). Only namespaces whose
|
|
23
|
+
* use genuinely requires a permission. `tabs` is intentionally excluded: the
|
|
24
|
+
* tabs namespace methods don't require the `tabs` permission (PRD §5 PERM002). */
|
|
25
|
+
export const namespaceToPermission = (() => {
|
|
26
|
+
const out = Object.create(null);
|
|
27
|
+
for (const [perm, mapping] of Object.entries(chromePermissionMap)) {
|
|
28
|
+
if (mapping.namespace)
|
|
29
|
+
out[mapping.namespace] = perm;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
})();
|
|
33
|
+
/** Some namespaces are granted by more than one permission string. Declaring
|
|
34
|
+
* ANY of these satisfies PERM002 for that namespace. (Real extensions commonly
|
|
35
|
+
* use declarativeNetRequestWithHostAccess instead of the base permission.) */
|
|
36
|
+
export const namespacePermissionAliases = Object.assign(Object.create(null), {
|
|
37
|
+
declarativeNetRequest: [
|
|
38
|
+
"declarativeNetRequest",
|
|
39
|
+
"declarativeNetRequestWithHostAccess",
|
|
40
|
+
"declarativeNetRequestFeedback",
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
/** All permission strings that satisfy a namespace's PERM002 requirement. */
|
|
44
|
+
export function permissionsForNamespace(namespace) {
|
|
45
|
+
if (namespacePermissionAliases[namespace])
|
|
46
|
+
return namespacePermissionAliases[namespace];
|
|
47
|
+
const p = namespaceToPermission[namespace];
|
|
48
|
+
return p ? [p] : [];
|
|
49
|
+
}
|
|
50
|
+
/** Broad host match patterns that trigger PERM003/004/005. */
|
|
51
|
+
export const BROAD_HOST_PATTERNS = ["<all_urls>", "*://*/*", "http://*/*", "https://*/*"];
|
|
52
|
+
export function isBroadHost(pattern) {
|
|
53
|
+
return BROAD_HOST_PATTERNS.includes(pattern);
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { loadManifest } from "./manifest.js";
|
|
2
|
+
import { discoverFiles, isScript, isHtml } from "./file-discovery.js";
|
|
3
|
+
import { collectManifestRefs } from "./manifest-refs.js";
|
|
4
|
+
import { parseScript } from "../parse/parse-script.js";
|
|
5
|
+
import { extractFromScripts } from "../parse/extract-api-calls.js";
|
|
6
|
+
import { runRules } from "../rules/index.js";
|
|
7
|
+
import { computeVerdict, filterByMinSeverity } from "./verdict.js";
|
|
8
|
+
/** Pure-ish engine entry point: directory in → LintReport out. Only manifest
|
|
9
|
+
* loading and file discovery touch the filesystem. Throws ManifestError (→ CLI
|
|
10
|
+
* exit 3) when the manifest is missing or invalid. */
|
|
11
|
+
export function analyze(rootDir, options) {
|
|
12
|
+
const { manifest, raw } = loadManifest(rootDir);
|
|
13
|
+
const manifestRefs = collectManifestRefs(manifest);
|
|
14
|
+
const files = discoverFiles(rootDir, manifestRefs);
|
|
15
|
+
const htmlFiles = files.filter((f) => isHtml(f.relPath));
|
|
16
|
+
const scriptFiles = files.filter((f) => isScript(f.relPath)).map(parseScript);
|
|
17
|
+
const extraction = extractFromScripts(scriptFiles);
|
|
18
|
+
const hostAccessSignals = [
|
|
19
|
+
...extraction.hostAccessSignals,
|
|
20
|
+
...deriveHostSignals(extraction.apiCalls),
|
|
21
|
+
...(manifest.content_scripts?.length ? [{ kind: "contentScript", file: "manifest.json" }] : []),
|
|
22
|
+
];
|
|
23
|
+
const model = {
|
|
24
|
+
rootDir,
|
|
25
|
+
manifest,
|
|
26
|
+
manifestRaw: raw,
|
|
27
|
+
files,
|
|
28
|
+
scriptFiles,
|
|
29
|
+
htmlFiles,
|
|
30
|
+
apiCalls: extraction.apiCalls,
|
|
31
|
+
sensitiveTabReads: extraction.sensitiveTabReads,
|
|
32
|
+
dynamicApiAccess: extraction.dynamicApiAccess,
|
|
33
|
+
hostAccessSignals,
|
|
34
|
+
manifestRefs: manifestRefs.all,
|
|
35
|
+
};
|
|
36
|
+
const ruleFindings = runRules(model);
|
|
37
|
+
const parseFindings = scriptFiles
|
|
38
|
+
.filter((s) => s.parseError)
|
|
39
|
+
.map((s) => ({
|
|
40
|
+
ruleId: "PARSE",
|
|
41
|
+
severity: "info",
|
|
42
|
+
confidence: "low",
|
|
43
|
+
title: "File could not be parsed",
|
|
44
|
+
message: `${s.relPath} failed to parse; its API usage was not analyzed. Findings for code in this file may be incomplete.`,
|
|
45
|
+
file: s.relPath,
|
|
46
|
+
}));
|
|
47
|
+
const allFindings = [...ruleFindings, ...parseFindings];
|
|
48
|
+
// --min-severity is a threshold, not just a display filter: it gates both the
|
|
49
|
+
// reported findings AND the verdict/exit code. This mirrors eslint --quiet
|
|
50
|
+
// (suppressing warnings makes a warning-only run exit 0). So `--min-severity
|
|
51
|
+
// reject` on a fix-only extension yields `pass`/exit 0 by design — the caller
|
|
52
|
+
// opted out of caring about fix-level findings.
|
|
53
|
+
const findings = filterByMinSeverity(allFindings, options.minSeverity ?? "info");
|
|
54
|
+
const verdict = computeVerdict(findings);
|
|
55
|
+
const scriptsParsed = scriptFiles.filter((s) => !s.parseError).length;
|
|
56
|
+
return {
|
|
57
|
+
tool: "tabsmith-lint",
|
|
58
|
+
version: options.version,
|
|
59
|
+
inputPath: rootDir,
|
|
60
|
+
manifestVersion: manifest.manifest_version,
|
|
61
|
+
browsers: {
|
|
62
|
+
chrome: {
|
|
63
|
+
verdict,
|
|
64
|
+
findings,
|
|
65
|
+
stats: {
|
|
66
|
+
filesScanned: files.length,
|
|
67
|
+
scriptsParsed,
|
|
68
|
+
parseErrors: scriptFiles.length - scriptsParsed,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function deriveHostSignals(apiCalls) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const c of apiCalls) {
|
|
77
|
+
if (c.namespace === "cookies")
|
|
78
|
+
out.push({ kind: "cookies", file: c.file, line: c.line });
|
|
79
|
+
else if (c.namespace === "webRequest")
|
|
80
|
+
out.push({ kind: "webRequest", file: c.file, line: c.line });
|
|
81
|
+
else if (c.namespace === "declarativeNetRequest")
|
|
82
|
+
out.push({ kind: "declarativeNetRequest", file: c.file, line: c.line });
|
|
83
|
+
else if (c.namespace === "scripting" && c.path.includes("executeScript"))
|
|
84
|
+
out.push({ kind: "scripting.executeScript", file: c.file, line: c.line });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, posix, dirname, resolve, sep } from "node:path";
|
|
3
|
+
import { stripBom } from "./source-lines.js";
|
|
4
|
+
const SCRIPT_EXT = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
|
5
|
+
const HTML_EXT = [".html", ".htm"];
|
|
6
|
+
const SCANNABLE = new Set([...SCRIPT_EXT, ...HTML_EXT]);
|
|
7
|
+
const EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache", "coverage"]);
|
|
8
|
+
function ext(path) {
|
|
9
|
+
const i = path.lastIndexOf(".");
|
|
10
|
+
return i < 0 ? "" : path.slice(i).toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
export function isScript(relPath) {
|
|
13
|
+
return SCRIPT_EXT.includes(ext(relPath));
|
|
14
|
+
}
|
|
15
|
+
export function isHtml(relPath) {
|
|
16
|
+
return HTML_EXT.includes(ext(relPath));
|
|
17
|
+
}
|
|
18
|
+
function toRel(rootDir, abs) {
|
|
19
|
+
return relative(rootDir, abs).split(/[\\/]/).join(posix.sep);
|
|
20
|
+
}
|
|
21
|
+
/** A leading "/" in a manifest reference is root-relative to the EXTENSION, not
|
|
22
|
+
* the filesystem (e.g. "/images/icon.png" means <ext-root>/images/icon.png).
|
|
23
|
+
* Normalize to a path relative to rootDir before any filesystem use. */
|
|
24
|
+
export function refToRelPath(p) {
|
|
25
|
+
return p.replace(/^[\\/]+/, "");
|
|
26
|
+
}
|
|
27
|
+
/** True when a manifest reference resolves to a location inside `rootDir`.
|
|
28
|
+
* Blocks "../secret.js" from escaping the package; allows root-relative "/x". */
|
|
29
|
+
export function isWithinRoot(rootDir, relPath) {
|
|
30
|
+
const root = resolve(rootDir);
|
|
31
|
+
const abs = resolve(rootDir, refToRelPath(relPath));
|
|
32
|
+
return abs === root || abs.startsWith(root + sep);
|
|
33
|
+
}
|
|
34
|
+
function readVirtual(rootDir, relPath, discoveredBy) {
|
|
35
|
+
const rel = refToRelPath(relPath);
|
|
36
|
+
if (!isWithinRoot(rootDir, rel))
|
|
37
|
+
return null; // never read/parse files outside the package
|
|
38
|
+
const absPath = join(rootDir, rel);
|
|
39
|
+
if (!existsSync(absPath) || !statSync(absPath).isFile())
|
|
40
|
+
return null;
|
|
41
|
+
return { relPath: rel, absPath, content: stripBom(readFileSync(absPath, "utf8")), discoveredBy };
|
|
42
|
+
}
|
|
43
|
+
const SCRIPT_SRC_RE = /<script[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
|
|
44
|
+
function localScriptSrcs(htmlContent, htmlRel) {
|
|
45
|
+
const out = [];
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = SCRIPT_SRC_RE.exec(htmlContent)) !== null) {
|
|
48
|
+
const src = m[1];
|
|
49
|
+
if (/^(https?:)?\/\//i.test(src) || src.startsWith("data:"))
|
|
50
|
+
continue; // remote/inline-data handled by MV3001
|
|
51
|
+
const resolved = posix.normalize(posix.join(dirname(htmlRel) || ".", src)).replace(/^\.\//, "");
|
|
52
|
+
out.push(resolved);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Two-pass discovery: manifest-directed entry points first (tagged "manifest"),
|
|
58
|
+
* then a package-wide fallback scan (tagged "fallback"). Manifest tags win on
|
|
59
|
+
* dedupe. node_modules/.git/build caches and source maps are excluded.
|
|
60
|
+
*
|
|
61
|
+
* Takes pre-collected ManifestRefs so the caller can reuse them (e.g. for
|
|
62
|
+
* model.manifestRefs) without collecting twice.
|
|
63
|
+
*/
|
|
64
|
+
export function discoverFiles(rootDir, refs) {
|
|
65
|
+
const byRel = new Map();
|
|
66
|
+
const add = (vf) => {
|
|
67
|
+
if (!vf)
|
|
68
|
+
return;
|
|
69
|
+
const existing = byRel.get(vf.relPath);
|
|
70
|
+
if (!existing || (existing.discoveredBy === "fallback" && vf.discoveredBy === "manifest")) {
|
|
71
|
+
byRel.set(vf.relPath, vf);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
// --- Pass 1: manifest-directed ---
|
|
75
|
+
for (const s of refs.scripts)
|
|
76
|
+
add(readVirtual(rootDir, s, "manifest"));
|
|
77
|
+
for (const p of refs.pages) {
|
|
78
|
+
const page = readVirtual(rootDir, p, "manifest");
|
|
79
|
+
add(page);
|
|
80
|
+
if (page && isHtml(p)) {
|
|
81
|
+
for (const src of localScriptSrcs(page.content, p))
|
|
82
|
+
add(readVirtual(rootDir, src, "manifest"));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// --- Pass 2: fallback scan ---
|
|
86
|
+
walk(rootDir, rootDir, (abs) => {
|
|
87
|
+
const rel = toRel(rootDir, abs);
|
|
88
|
+
if (rel === "manifest.json")
|
|
89
|
+
return;
|
|
90
|
+
if (rel.endsWith(".map"))
|
|
91
|
+
return;
|
|
92
|
+
if (!SCANNABLE.has(ext(rel)))
|
|
93
|
+
return;
|
|
94
|
+
add(readVirtual(rootDir, rel, "fallback"));
|
|
95
|
+
if (isHtml(rel)) {
|
|
96
|
+
const page = byRel.get(rel);
|
|
97
|
+
for (const src of localScriptSrcs(page.content, rel))
|
|
98
|
+
add(readVirtual(rootDir, src, "fallback"));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return [...byRel.values()];
|
|
102
|
+
}
|
|
103
|
+
function walk(rootDir, dir, onFile) {
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
const abs = join(dir, e.name);
|
|
113
|
+
if (e.isDirectory()) {
|
|
114
|
+
if (EXCLUDE_DIRS.has(e.name))
|
|
115
|
+
continue;
|
|
116
|
+
walk(rootDir, abs, onFile);
|
|
117
|
+
}
|
|
118
|
+
else if (e.isFile()) {
|
|
119
|
+
onFile(abs);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
function pushStr(out, source, v) {
|
|
2
|
+
if (typeof v === "string" && v.length > 0)
|
|
3
|
+
out.push({ ref: v, source });
|
|
4
|
+
}
|
|
5
|
+
function pushList(out, source, v) {
|
|
6
|
+
if (Array.isArray(v))
|
|
7
|
+
for (const item of v)
|
|
8
|
+
pushStr(out, source, item);
|
|
9
|
+
}
|
|
10
|
+
/** Collect file references from the manifest. Glob entries (containing '*') are
|
|
11
|
+
* dropped from `all` since they can't be existence-checked. */
|
|
12
|
+
export function collectManifestRefs(manifest) {
|
|
13
|
+
const all = [];
|
|
14
|
+
const pages = [];
|
|
15
|
+
const scripts = [];
|
|
16
|
+
const m = manifest;
|
|
17
|
+
// background
|
|
18
|
+
pushStr(all, "background.service_worker", m.background?.service_worker);
|
|
19
|
+
if (m.background?.service_worker)
|
|
20
|
+
scripts.push(m.background.service_worker);
|
|
21
|
+
pushList(all, "background.scripts", m.background?.scripts);
|
|
22
|
+
if (Array.isArray(m.background?.scripts))
|
|
23
|
+
scripts.push(...m.background.scripts);
|
|
24
|
+
// content scripts
|
|
25
|
+
if (Array.isArray(m.content_scripts)) {
|
|
26
|
+
for (const cs of m.content_scripts) {
|
|
27
|
+
pushList(all, "content_scripts.js", cs?.js);
|
|
28
|
+
pushList(all, "content_scripts.css", cs?.css);
|
|
29
|
+
if (Array.isArray(cs?.js))
|
|
30
|
+
scripts.push(...cs.js);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// action popup + icons
|
|
34
|
+
pushStr(all, "action.default_popup", m.action?.default_popup ?? m.browser_action?.default_popup);
|
|
35
|
+
const popup = m.action?.default_popup ?? m.browser_action?.default_popup;
|
|
36
|
+
if (typeof popup === "string")
|
|
37
|
+
pages.push(popup);
|
|
38
|
+
collectIcons(all, "action.default_icon", m.action?.default_icon);
|
|
39
|
+
collectIcons(all, "icons", m.icons);
|
|
40
|
+
// pages
|
|
41
|
+
for (const [key, val] of [
|
|
42
|
+
["options_page", m.options_page],
|
|
43
|
+
["options_ui.page", m.options_ui?.page],
|
|
44
|
+
["devtools_page", m.devtools_page],
|
|
45
|
+
["side_panel.default_path", m.side_panel?.default_path],
|
|
46
|
+
]) {
|
|
47
|
+
if (typeof val === "string") {
|
|
48
|
+
pushStr(all, key, val);
|
|
49
|
+
if (val.endsWith(".html"))
|
|
50
|
+
pages.push(val);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// chrome_url_overrides
|
|
54
|
+
if (m.chrome_url_overrides && typeof m.chrome_url_overrides === "object") {
|
|
55
|
+
for (const [k, v] of Object.entries(m.chrome_url_overrides)) {
|
|
56
|
+
pushStr(all, `chrome_url_overrides.${k}`, v);
|
|
57
|
+
if (typeof v === "string" && v.endsWith(".html"))
|
|
58
|
+
pages.push(v);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// web accessible resources (skip globs)
|
|
62
|
+
if (Array.isArray(m.web_accessible_resources)) {
|
|
63
|
+
for (const war of m.web_accessible_resources) {
|
|
64
|
+
if (Array.isArray(war?.resources)) {
|
|
65
|
+
for (const r of war.resources) {
|
|
66
|
+
if (typeof r === "string" && !r.includes("*"))
|
|
67
|
+
pushStr(all, "web_accessible_resources", r);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (typeof war === "string" && !war.includes("*")) {
|
|
71
|
+
// MV2-style flat array
|
|
72
|
+
pushStr(all, "web_accessible_resources", war);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// declarative net request rulesets
|
|
77
|
+
const rulesets = m.declarative_net_request?.rule_resources;
|
|
78
|
+
if (Array.isArray(rulesets)) {
|
|
79
|
+
for (const rs of rulesets)
|
|
80
|
+
pushStr(all, "declarative_net_request.path", rs?.path);
|
|
81
|
+
}
|
|
82
|
+
return { all, pages: dedupe(pages), scripts: dedupe(scripts) };
|
|
83
|
+
}
|
|
84
|
+
function collectIcons(out, source, icons) {
|
|
85
|
+
if (typeof icons === "string") {
|
|
86
|
+
pushStr(out, source, icons);
|
|
87
|
+
}
|
|
88
|
+
else if (icons && typeof icons === "object") {
|
|
89
|
+
for (const v of Object.values(icons))
|
|
90
|
+
pushStr(out, source, v);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function dedupe(xs) {
|
|
94
|
+
return [...new Set(xs)];
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { stripBom } from "./source-lines.js";
|
|
4
|
+
export class ManifestError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Load and minimally validate manifest.json from an extension directory.
|
|
8
|
+
* Throws ManifestError (→ CLI exit 3) on a missing dir, missing/unreadable
|
|
9
|
+
* manifest, malformed JSON, or absent manifest_version. We deliberately do NOT
|
|
10
|
+
* deep-validate the schema here — rules handle semantics.
|
|
11
|
+
*/
|
|
12
|
+
export function loadManifest(rootDir) {
|
|
13
|
+
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
|
|
14
|
+
throw new ManifestError(`Not a directory: ${rootDir}`);
|
|
15
|
+
}
|
|
16
|
+
const manifestPath = join(rootDir, "manifest.json");
|
|
17
|
+
if (!existsSync(manifestPath)) {
|
|
18
|
+
throw new ManifestError(`No manifest.json found in ${rootDir}`);
|
|
19
|
+
}
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = stripBom(readFileSync(manifestPath, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new ManifestError(`Could not read manifest.json: ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
let manifest;
|
|
28
|
+
try {
|
|
29
|
+
manifest = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
throw new ManifestError(`manifest.json is not valid JSON: ${e.message}`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof manifest !== "object" || manifest === null) {
|
|
35
|
+
throw new ManifestError("manifest.json must be a JSON object");
|
|
36
|
+
}
|
|
37
|
+
if (typeof manifest.manifest_version !== "number") {
|
|
38
|
+
throw new ManifestError("manifest.json is missing a numeric manifest_version");
|
|
39
|
+
}
|
|
40
|
+
return { manifest, raw };
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ponytail: naive first-match line lookup. Manifest findings get an approximate
|
|
2
|
+
// line by searching the raw JSON text for a needle. Good enough for a hint;
|
|
3
|
+
// upgrade to a real JSON AST (jsonc-parser) only if precise locations are needed.
|
|
4
|
+
export function findLine(source, needle) {
|
|
5
|
+
if (!needle)
|
|
6
|
+
return undefined;
|
|
7
|
+
const lines = source.split(/\r?\n/);
|
|
8
|
+
for (let i = 0; i < lines.length; i++) {
|
|
9
|
+
if (lines[i].includes(needle))
|
|
10
|
+
return i + 1;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
/** 1-based line number for a character offset into `source`. */
|
|
15
|
+
export function findLineByIndex(source, index) {
|
|
16
|
+
return source.slice(0, index).split("\n").length;
|
|
17
|
+
}
|
|
18
|
+
/** Strip a leading UTF-8 BOM (U+FEFF). Windows/PowerShell-authored files often
|
|
19
|
+
* carry one, which would otherwise break JSON.parse and source scanning. */
|
|
20
|
+
export function stripBom(s) {
|
|
21
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Canonical severity ranking — the single source of truth for ordering and
|
|
2
|
+
* filtering. Higher = more severe. */
|
|
3
|
+
export const SEVERITY_RANK = { reject: 2, fix: 1, info: 0 };
|
|
4
|
+
/** Most-severe-first order, for grouped display and stable sorting. */
|
|
5
|
+
export const SEVERITY_ORDER = ["reject", "fix", "info"];
|
|
6
|
+
export function filterByMinSeverity(findings, min) {
|
|
7
|
+
return findings.filter((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[min]);
|
|
8
|
+
}
|
|
9
|
+
export function computeVerdict(findings) {
|
|
10
|
+
if (findings.some((f) => f.severity === "reject"))
|
|
11
|
+
return "high_rejection_risk";
|
|
12
|
+
if (findings.some((f) => f.severity === "fix"))
|
|
13
|
+
return "needs_fixes";
|
|
14
|
+
return "pass";
|
|
15
|
+
}
|
|
16
|
+
export function verdictExitCode(verdict) {
|
|
17
|
+
switch (verdict) {
|
|
18
|
+
case "pass":
|
|
19
|
+
return 0;
|
|
20
|
+
case "needs_fixes":
|
|
21
|
+
return 1;
|
|
22
|
+
case "high_rejection_risk":
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
}
|