chrome-review
v0.1.0
Published
Pre-submission auditor for Chrome Web Store (Manifest V3) extensions. Static analysis: permission-to-usage diff, remote-hosted-code detection, host-permission overbreadth, source-map-aware bundled-code honesty. No extension code is executed. No telemetry.
Maintainers
Readme
chrome-review — CLI
Static analyzer CLI for unpacked Manifest V3 Chrome extensions. No extension
code is executed. The only outbound HTTP request the CLI makes is a codeload
tarball fetch against github.com when the input is a GitHub URL (Pass 3 /
W1-P3 / Decision D29). No telemetry, no phone-home, no OAuth flow.
Invocations
# Local unpacked extension directory
npx chrome-review ./path/to/extension
# Public GitHub repo, default branch (tries main, then master)
npx chrome-review https://github.com/<owner>/<repo>
# Specific branch, tag, or commit SHA
npx chrome-review https://github.com/<owner>/<repo>/tree/<ref>
# Specific ref + subpath as the extension root
npx chrome-review https://github.com/<owner>/<repo>/tree/<ref>/<subpath>npx chrome-review@<version> resolves to the published npm package (see
docs/release.md for the release runbook). In a
cloned workspace, npx chrome-review <input> resolves to the local
workspace-installed bin and also works after npm install && npm run build.
<path> must be a directory containing a manifest.json.
Private-beta tarball install
The private beta is distributed through local npm pack tarballs, not the npm
registry. Install all four tarballs together because the CLI depends on the
internal parser/rules/types packages by package name:
npm run build
PACK_DIR=$(mktemp -d)
npm pack --pack-destination "$PACK_DIR" \
--workspace @chrome-review/types \
--workspace @chrome-review/parser \
--workspace @chrome-review/rules \
--workspace chrome-review
SMOKE_DIR=$(mktemp -d)
cd "$SMOKE_DIR"
npm init -y
npm install \
"$PACK_DIR"/chrome-review-types-0.1.0.tgz \
"$PACK_DIR"/chrome-review-parser-0.1.0.tgz \
"$PACK_DIR"/chrome-review-rules-0.1.0.tgz \
"$PACK_DIR"/chrome-review-0.1.0.tgz
npx chrome-review /absolute/path/to/unpacked-extension
npx chrome-review --json /absolute/path/to/unpacked-extensionThis is a private-beta packaging path only. It is not an npm publish flow and does not promise a stable 1.0 JSON contract.
Environment
| Variable | Purpose |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GITHUB_TOKEN | If set, sent as Authorization: Bearer <token> on the codeload tarball fetch. Required for private repos and lifts the unauthenticated rate limit. Never logged. (D30.) |
No config file. No other environment variables. The CLI never reads
~/.chrome-review/* or similar.
GitHub URL ingestion lifecycle
When the input is a GitHub URL, the CLI:
- Parses the URL into
{owner, repo, ref?, subpath?}. - Fetches
https://codeload.github.com/<owner>/<repo>/tar.gz/<ref>via Node's nativefetch. If no ref was given, triesmainthenmaster. - Extracts the tarball into a fresh subdirectory of
os.tmpdir()namedchrome-review-<random>. - Analyzes the extracted repo (or the subpath subtree) exactly as if it were a local directory.
- Cleans up the temp directory — on success, on every typed error
path, and on the four process-level exit modes (
exit,SIGINT,SIGTERM,uncaughtException). The cleanup LOGIC for each mode is unit-tested (via test-only exports that run the handler body without actually signaling the test process). Handler idempotency — callingensureProcessCleanupHandlersmultiple times never adds duplicate listeners — is also pinned by a unit test. Actual OS signal delivery is not exercised in automated tests because realSIGINTwould kill the test runner; manual smoke via SIGINT'ing a running CLI is documented indocs/release.md.
Known limitations (GitHub URL ingestion)
The fetch path in packages/cli/src/github-url.ts is deliberately
minimal in v0.1.0. Three hardening items are deferred to a later pass
and listed in ROADMAP.md's next-pass candidates:
- No request timeout. A hung
codeload.github.comresponse will block the CLI indefinitely. Abort withCtrl-C; the temp directory is removed by theSIGINTcleanup handler. - No size limit. The full repository tarball is always
downloaded — the
/tree/<ref>/<subpath>URL form narrows what the analyzer walks after extraction, but codeload still ships the whole repo. Very large repositories will consume bandwidth and disk accordingly; clone locally and run against a path for tighter control. - No automatic retry. Transient network failures
(
fetch-thrown errors, 5xx responses) exit immediately with aRetry, or analyze locally after cloningsuggestion in the error message.
Options
| Option | Description |
| -------------- | ------------------------------------------------- |
| --json | Emit the full AnalyzerReport as JSON on stdout. |
| -h, --help | Print usage and exit 0. |
Default output is a human-readable, multi-section report keyed off the same
underlying AnalyzerReport structure that --json emits verbatim.
Exit codes
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Analyzer ran cleanly. May still include LOW / MEDIUM findings — they are informational. |
| 1 | Analyzer ran AND produced ≥1 HIGH-confidence finding (undeclared-but-used permission, OR remote-hosted-code). Treat this as a high-risk static pre-submission signal, not a guaranteed Chrome Web Store outcome. |
| 1 | Analyzer threw an uncaught exception (manifest unreadable, walker crash, etc). The exception message is printed to stderr. |
| 2 | CLI usage / input error: unknown flag, wrong arg count, path doesn't exist or isn't a directory, OR a recognized GitHub URL ingestion failure (see below). |
Code 1 covers two runtime cases (HIGH finding vs analysis exception).
Disambiguate via stderr: an exception writes Analysis failed: ...; a HIGH
finding writes only to stdout.
GitHub URL error cases (all exit 2)
Every failure is surfaced as a specific actionable message, not a generic "not found" / "error". The message classes, each pinned by a unit test in tests/unit/github-url.test.mjs:
| GitHubUrlError.kind | When | Message shape |
| --------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| invalid_url | URL didn't match the three accepted shapes. | Invalid GitHub URL: <url> followed by the three accepted shapes. |
| not_found | 404 on every candidate ref (including the main/master fallback). | Repository `<owner>/<repo>` not found at ref `<ref>`. Check the URL. If this is a private repo, set GITHUB_TOKEN. |
| auth_failed | 401 from codeload, or 403 without rate-limit headers. | GITHUB_TOKEN rejected. Verify the token is valid and has \repo` scope for private repos.(401) or a repo-may-be-private variant. |
|rate_limited | 403 withx-ratelimit-remaining: 0or anx-ratelimit-reset. | GitHub rate limit exceeded. Resets at . Set GITHUB_TOKEN to lift the limit, or wait. |
|network |fetchthrew (ECONNRESET, timeout, DNS) or non-4xx status. |Network error fetching tarball: . Retry, or analyze locally after cloning. |
|extract | Tarball extracted but the bytes were not a valid gzipped tar. |Failed to extract tarball: . The repo may have an unusual structure. |
|subpath_missing | Subpath didn't exist inside the extracted tarball. | ``Subpathnot found in/@`. Available top-level entries: .`` |
CI scripts that need to distinguish a HIGH-finding exit 1 from an
analysis-exception exit 1 should inspect --json: a HIGH finding produces
a valid report; an exception produces no JSON at all.
--json output shape
--json prints a single JSON object conforming to
schema/report.schema.json (JSON Schema draft-07).
The schema is 0.x — UNSTABLE. Field additions, renames, and removals can occur in any 0.x release. The schema will be frozen at 1.0; until then, treat the JSON as best-effort machine-readable output rather than a stable contract.
Schema versioning
Starting with 0.3.0 (Pass 2 reopen 2 / W5-R2 / Decision D21), the
schema $id encodes the current version and every emitted report
carries a top-level schemaVersion field so downstream tools can key
behavior on it. Current version is 0.3.1 (Pass 3 / W3-P3 added
side_panel_html to EntryPoint.kind).
REPORT_SCHEMA_VERSION is exported from
@chrome-review/types; the CLI fills the field
when it builds the report. A unit test
(tests/unit/json-schema.test.mjs)
asserts the schema's const, the schema $id, and the runtime
export stay in lock-step.
Pass 3 schema changes bump the version. The $id moves, the
schemaVersion.const in the schema moves, and REPORT_SCHEMA_VERSION
moves — all in the same commit.
Changelog
0.3.1 — current (Pass 3 / W3-P3 / Decision D34). Additive-only widening of
EntryPoint.kindenum to includeside_panel_html, and the correspondingManifestAnalysis.sidePanelHtml?: DeclaredStringfield surfacing the manifest'sside_panel.default_path. Reports from extensions without a side panel are byte-for-byte compatible with 0.3.0; consumers that enumerateEntryPoint.kindvalues should addside_panel_htmlto their switch (or treat unknown values permissively).0.3.0 (Pass 2 reopen 2 / reopen 3). First versioned release; cumulative state of the Pass 2 reopen + reopen-2 + reopen-3 era. Adds:
schemaVersiontop-level field, required,const: "0.3.0".- Pass 2 reopen 3 / W2-R3 —
ApiUsage.escapeForm?: string(enum ofreturn_escape/top_level_let/top_level_var/function_scope/block_scope/rebinding/object_property_store/assignment_binding). Additive optional field naming the specific escape form behind anescape_scopeusage. Absent on non-escape usages. Added under the 0.x UNSTABLE disclaimer without a version bump; downstream consumers that ignore unknown optional fields are unaffected. - Pass 2 reopen 2 / W2-R2 —
ExecuteScriptInjection.contributedHostPattern?: stringrecording a URL pattern threaded from a statically analyzable surroundingchrome.tabs.query({url: LITERAL})callback. - Pass 2 reopen W5 —
ExecuteScriptInjectionrename:loc→site;targetis now a discriminated union ({resolved: true, tabId?, frameIds?, documentIds?, allFrames?}or{resolved: false, reason: string});origin: 'tabs_legacy'→'tabs-legacy'. - Pass 2 reopen W2 — new
UnresolvedReference.reasonvalues:dynamic_network_url_non_literal,bare_module_specifier_not_resolved. - Pass 2 reopen W1 —
PartiallyAnalyzedFile.resolvedSources?: string[].
0.2.x — pre-versioning era (Pass 2 initial close-out, reconstructed from git history). The
$idin that era wasreport-0.x.jsonand reports had noschemaVersionfield. Consumers that saw reports withoutschemaVersionare reading pre-0.3.0 output. The cumulative schema shape at end of 0.2 corresponds to Pass 2 §A1–§A7 landings.0.1.x — initial Pass 1 (reconstructed from git history). First
AnalyzerReportshape; permission findings, remote-code findings, host-scope findings, caveats. No executeScript / networkUrlLiteral / partially-analyzed extensions yet.
Recent breaking changes
- Pass 2 reopen 2 / W5-R2 (Decision D21) — schema versioning.
See "Schema versioning" above. The
$idmoved fromreport-0.x.jsontoreport-0.3.0.json;schemaVersion: "0.3.0"is now a required field on every emitted report. - Pass 2 reopen 2 / W2-R2 (Decision D20) —
ExecuteScriptInjection.contributedHostPattern. Optional string carrying a URL pattern (match pattern or literal URL prefix) when the parser detected that the injection'starget.tabIdwas threaded from a surroundingchrome.tabs.query({url: LITERAL})callback. Host-scope folds this host into observed scope; consumers can surface it directly. - Pass 2 reopen W5 (Decision D16) —
ExecuteScriptInjectionshape. Three field renames in one go:loc→site(sameSourceLocationshape).- top-level
targetReasonremoved; folded intotargetas a discriminated union:{ resolved: true, tabId?, frameIds?, documentIds?, allFrames? }when statically resolvable,{ resolved: false, reason: string }when not. The set ofreasonvalues is unchanged. origin: 'tabs_legacy'→'tabs-legacy'(kebab-case).
- Pass 2 reopen W2 — new
UnresolvedReference.reasonvalues.dynamic_network_url_non_literal— emitted for fetch / XHR / WebSocket / EventSource / sendBeacon / importScripts calls whose URL argument is fully dynamic or a template literal with no extractable host.bare_module_specifier_not_resolved— emitted for CJSrequire('pkg')and ESMimport x from 'pkg'(D15: the analyzer narrows the contract to local-looking specifiers; bare imports are recorded but not resolved).
- Pass 2 reopen W1 —
PartiallyAnalyzedFilegainedresolvedSources. Optional string array, populated forsource_map_resolvedentries with the on-disk paths of original sources the map declares.
The schema is validated against every corpus entry's --json output as part
of the test suite (tests/unit/json-schema.test.mjs). A
schema break will fail CI.
The top-level shape (corresponds to AnalyzerReport in
@chrome-review/types):
{
"schemaVersion": "0.3.1",
"parser": {
"extensionRoot": "/abs/path/to/extension",
"manifest": {
/* ManifestAnalysis */
},
"entryPoints": [
/* EntryPoint[] */
],
"apiUsages": [
/* ApiUsage[] */
],
"unresolved": [
/* UnresolvedReference[] */
],
"scannedFiles": ["bg.js", "popup.js"],
"unparseableFiles": ["dist/bundle.js"],
"htmlFiles": ["popup.html"],
"dnrRuleSets": [
/* DnrRuleSetSummary[] */
],
"notes": [
/* AnalysisNote[] */
],
},
"rules": {
"permissionFindings": [
/* PermissionFinding[] */
],
"remoteCodeFindings": [
/* RemoteCodeFinding[] */
],
"hostScopeFindings": [
/* HostScopeFinding[] */
],
"caveats": [
/* AnalysisNote[] */
],
},
}Each finding object carries confidence: "HIGH" | "MEDIUM" | "LOW". The
exit-code policy keys off confidence === "HIGH" for undeclared_but_used
permission findings and any remoteCodeFindings entry.
Network behavior
The CLI makes exactly one kind of outbound HTTP request: a tarball fetch to
codeload.github.com when a GitHub URL is provided as the positional
argument. Local path inputs trigger no network activity.
What the CLI does NOT do
- Execute extension code.
- Phone home / emit telemetry.
- Guarantee Chrome Web Store approval or guarantee rejection prevention.
- Suppress findings — there is no
.chrome-review.jsonignore mechanism in v0.1.0. (Listed underROADMAP.mdnext-pass candidates.)
