Security scanner for Acode plugin zips. It reads the archive the same way the
Acode installer does, analyses every JavaScript/HTML file with Oxc (including
semantic scope analysis), and returns a verdict: pass, review, or
block. A diff command compares two versions, which is the view that
matters when an approved plugin is updated.
The scanner only reports. It never changes or rejects a plugin on its own; the site or CI decides what to do with the verdict.
cargo build --release
# Human-readable report (default)
plugin_scanner scan plugin.zip
# JSON for servers; exit code 1 if the verdict is review or worse
plugin_scanner scan plugin.zip --json --fail-on review
# Compare the published version with an uploaded update
plugin_scanner diff published.zip update.zip --format json --fail-on review
# Update an installed binary in place to the latest release
plugin_scanner update| Command / flag | What it does |
|---|---|
scan <zip> |
Full scan. Formats: --format terminal (default), json, md (reviewer report), summary (short user-facing disclosure). Shorthands: --json, --markdown, --summary. |
diff <old> <new> |
What changed between two versions: new or escalated findings, new hosts, modules, permissions, changed files. --format terminal|json|md. |
update |
Replace the running binary with the latest GitHub release (Linux x86_64/arm64, macOS arm64). --check only reports, --version 0.3.0 installs a specific release (also downgrades), --force reinstalls. Needs curl or wget. The download is checked against the published .sha256 and GitHub's digest, and run once before it replaces anything; use sudo if the binary is in a system folder. |
--fail-on <level> |
Exit 1 when reached. For scan: info…critical (highest severity) or review / block (recommendation). For diff: review / block. |
--entry-only |
Only analyse the script Acode loads. By default every JS/HTML file is analysed, because the entry script can load the rest at runtime. |
--max-entry-bytes, --max-total-bytes, --max-entries |
Zip-bomb limits (defaults: 32 MiB per entry, 256 MiB total, 5000 entries). |
Exit codes: 0 scan finished (threshold not reached), 1 --fail-on
threshold reached, 2 unreadable archive or other error.
| Recommendation | Meaning | Typical cause |
|---|---|---|
pass |
Nothing above Medium. Safe to publish without a human look. | Normal editor plugins: fs, fetch, commands, storage, settings. Terminal plugins whose shell commands are all visible (apk add, npm install, uname -m). |
review |
Something powerful enough that a person should look. | Shell commands the scanner cannot see (built at runtime, sh -c $x, piped into a shell), all-files permission, Play billing, obfuscation, tampering with other plugins, decoded code execution, scan incomplete. |
block |
A Critical finding: no legitimate plugin needs it, or the zip is built to confuse the installer. | Remote code or shell commands, core-module hijack, download-and-run shell commands, plugin-context bridge calls, planting an acode.app session, rewriting its own entry script, exfiltration to webhooks, install-path collisions, a main the loader resolves outside the plugin, no plugin.json. |
For updates, use diff. If nothing new appears in the diff, the update is
pass, even for a plugin that needed a review on its full scan. Updates that
add a High/Critical capability, a new declared permission, or a new flagged
host are review (or block). So are updates that keep a capability but use
it with new arguments: a terminal plugin that starts running a different
shell command (any command with side effects, even a Medium one; new
read-only commands don't count), a new native Service.action, or a new
remote script URL.
Severity guide:
- Critical: no legitimate plugin does this.
- High: powerful; a person should look.
- Medium: worth telling users; normal for some plugins.
- Low / Info: inventory only, never affects the verdict.
The first version matched API names in text, which flagged almost every real
plugin as critical (e.read(), n.copy(), window.cordova, webpack's
Function("return this"), font base64, every URL). Now:
- Locals vs globals. Oxc's semantic analysis means a parameter called
systemor a localfetchis not the Acode global. - Real Acode surface. Rules are keyed on what Acode exposes:
window.*globals fromsrc/lib, Cordova clobbers (system,sdcard,Executor,Terminal,ftp,sftp,iap,CreateServer,cordova.plugin.http) and the modules registered inlib/acode.js. Generic method names on unknown receivers are, at most, Low-confidence hints. - Context-graded sinks.
eval(x)with an unknown argument is Medium. It becomes High when the argument comes from a decoder (atob,fromCharCode...) and Critical when it comes from a network response. Bundler idioms (Function("return this"),<script>chunk loaders with relative URLs) are ignored. - Shell commands are graded by what they run. Each command is split like
a shell would (
;,&&,||, pipes, subshells,$(…)), and each part is graded:- Low: only read-only commands (
uname -m,command -v x,cat,ls,test -f,tool --version,npm root -g). - Medium: visible commands with side effects (installing packages,
moving files, starting a local server, running a proot container), and
any command with a value the scanner can't work out (
cat "${path}"). - High: the scanner can't see what runs: the command name is built at
runtime or is an unset
$VAR,sh -c/eval/node -eget a runtime string, or text is piped into a shell or interpreter. - Known-bad patterns (download-and-run, reverse shells, wiping
storage...) are still matched on top of this. They only match the
dangerous form:
rm -rf ~/is Critical,rm -rf ~/.cache/xis not; reading/etc/profileis not persistence; Acode's own/data/data/com.foxdebug.acode…folder is not another app's.
- Low: only read-only commands (
- Shell helpers are followed. In
const sh = c => Executor.execute(c), eachsh("…")call is graded with its own command, also when the sink sits in a callback inside the helper (new Promise(r => Executor.start(cmd, …))). When every use of the helper is a direct call, the helper's body adds no finding of its own. The scanner also followsthis.run(cmd)methods, getters such asgetExecutor(), quoting helpers ('${s.replace(/'/g, …)}',JSON.stringify), functions that build a script from a template,$VARnames assigned earlier in the same script, every branch ofa ? "x" : "y", andbtoa(…)or a local base64 helper that ends inbtoa. - Encoded and staged scripts are opened. Base64 piped through
base64 -din a command (printf %s <b64> | base64 -d > /tmp/x.sh,echo <b64> | base64 -d | sh) is decoded and checked like any other command (encoded_script). When a file is written with content the scanner can't read and is then run (sh /tmp/x.sh, from Executor or a terminal tab), that isstaged_script(H), and the file's multi-line, script-shaped constants are checked as the likely content. - Source built at runtime is listed.
git clonefollowed by a build (make,cmake,npm install,cargo build, …),pip install git+…, andnpm install github:…arebuilds_remote_source, naming each repository (for R in a b c; do git clone …/$Rloops are expanded). Medium when unpinned, Low when pinned to a tag or commit (--branch v1.2,git checkout <sha>,pkg@<ref>). - Process input is not a command. Text written with
Executor.writeto a program the plugin started (a language server, a kernel driver) is input (Low). It is graded as a command only when the started process is a shell or can't be seen. Unknown text typed into a terminal tab is Medium (it is usually log output); a runtime command followed by Enter is High. - Aggregation. One finding per rule, stable key, and file, with an occurrence count and up to 8 locations.
- Endpoints are inventory. URLs go into an
endpointslist. Only known exfiltration or payload hosts become findings: request bins, paste sites, tunnels, shorteners, raw public IPs,.onion. Telegram, Discord, Slack and Apps Script URLs are High only when their secret (bot token, webhook id and token, script id) is written into the plugin, since that channel belongs to the author. Just the API base, with the token added at runtime from the user's settings, is Low (bot-api). A Telegram bot token anywhere in a string ishardcoded_bot_token(H), even when it is split from the URL. - Own storage is not user data. Reading a plugin's own
localStoragekeys doesn't count toward exfiltration. Dumping all of it (Object.keys,JSON.stringify(localStorage), spreading it,for…in,.key(i)) isweb_storage_dump(M) and does. - Base64 is decoded. A long base64 string is only flagged when it decodes to JavaScript.
-
Installer-accurate archive model. Acode installs with JSZip and its own
sanitizeZipPath, which collapses..instead of rejecting it and keeps the last duplicate. The scanner mirrors that exactly, and flags these cases:x/../main.jsoverwritingmain.js- duplicate entries
- absolute paths
- encrypted entries
- compression methods other than stored/deflate
- native binaries detected by magic bytes
- code under
__MACOSX/,.DS_StoreorThumbs.dbnames, which unzip tools hide but Acode installs (real metadata is recognised by its header) - local file headers that disagree with the central directory ("zip cloaking"): Acode and the scanner read the central directory, but tools that read local headers would see different files
Overwritten entries are still analysed. Names are decoded as UTF-8, as JSZip does.
-
Entry resolution like
installPlugin.jsandloadPlugin.js.mainis looked up by exact key, so"./dist/main.js"falls back tomain.js, just as on device. The loader then joins the rawmainonto the plugin folder, so amainthat climbs out with..runs a file that was never in the zip, and one with backslashes makes the loader fall back tomain.js. -
Alias and constant-string resolution. These all resolve:
const w = window,const { exec } = cordovaacode.require("fs")stored in a variablewindow["ev" + "al"],atob("ZXZhbA==")String.fromCharCode(...),[...].join(""),"lave".split("").reverse().join("")(0, fn)(...),fn.call(t, ...),fn.apply(t, [...]),fn.bind(t)(...),Reflect.apply(fn, t, [...])
-
One-hop taint. Values built from
fetch/XMLHttpRequestresponses or decoders are tracked through variables and.then(...)chains, intoeval, shell commands, and files written where code is loaded from. -
WebAssembly is opened up. Shipped modules (found by header, whatever their extension) and base64 modules inlined in JS are parsed: imports that run JavaScript strings are flagged, and URLs in data segments go through the same endpoint and exfiltration checks as JS.
-
Zip-bomb safe. Reads are capped by the limits, not by declared sizes. Hitting a limit marks the scan incomplete, which forces
review.
| Area | Rule IDs (severity) |
|---|---|
| Archive | install_path_collision (C), path_traversal, duplicate_entry, encrypted_entry, unsupported_compression, oversized_entry, total_size_limit, too_many_entries, native_binary, header_mismatch (H), absolute_path, nested_archive, metadata_path_content (M) |
| Manifest | missing, invalid_json, no_entrypoint, main_escapes_plugin_dir (C), missing_id/name/version, readme_password_form (readme or changelog renders a password form; DOMPurify keeps forms) (H), invalid_id, invalid_version, invalid_price, invalid_min_version_code, missing_icon, missing_readme, non_js_entry, main_loader_mismatch, readme_form (M), dependencies, icon_too_large (L), main_fallback (I) |
| Shell | remote_command (C), decoded_command (H), dangerous_command (C/H/M: download-and-run, decode-and-run, reverse shell, miner, wiping /sdcard/$HOME, other apps' data, su, download-then-chmod, SSH keys / stored credentials, curl file uploads, piping env or files to the network, untrusted package sources, cron/boot persistence, shell-profile persistence), load_library (C), exec (H/M/L by what the command runs; keys end in :program, :inspect, or write:input), terminal_module, terminal_environment, process_control (M/H), staged_script (H), encoded_script, builds_remote_source (M; L when pinned), script_file (L; shipped .sh / #! scripts get the same command checks) |
| Native | plugin_context_bridge (cordova.exec to Tee), cordova_exec_dynamic (C), account_bridge (Authenticator; C for saveToken, else H), cordova_exec, manage_all_files, in_app_purchase (H), permission_request, launch_app, intent_access, purchase_read, cordova_require, cordova_plugin (M), file_intent, device_setting, os_integration (L) |
| Tampering | core_module_override (acode.define("fs") etc.) (C), self_modify (writes scripts or plugin.json where code is loaded from; C when the content is downloaded or it rewrites the entry/manifest, else H), acode_api, cordova_bridge, network_hook, native_api (hooks system, Executor, sdcard…), dom_hook (wraps addEventListener, appendChild, input value…), ads, plugin_settings (edits pluginsDisabled), other_plugin, plugin_files (writes into another plugin's folder), rewards (H), editor_manager, define_dynamic, unmount_dynamic (M) |
| Dynamic code | remote_code, remote_import (C), remote_script, remote_worker, decoded_exec, indirect_function_constructor (H), eval, function_constructor, inline_script, script_blob, html_script, remote_html, decoded_html (M: innerHTML, insertAdjacentHTML, createContextualFragment, srcdoc, on* attributes), string_timer, script_element, import_expression, inline_handler (L) |
| Acode API | webview_remote_page (M), webview, webview_script, intent_module, intent_handler, require_dynamic (L), install_plugin (M), editor_hook, require_module, define_module, global_key_listener (I) |
| Network | suspicious_endpoint (H/M/L by host type), hardcoded_bot_token (H), remote_command, acode_account_write (H: non-GET to acode.app/api, which Acode sends with the user's cookies), acode_account_read (M), native_http, remote_storage, local_server, beacon (M), fetch, xhr, websocket, event_source, open_browser (L) |
| Filesystem / storage | delete, sdcard_write, system_write, system_exec_bits, storage_access_request, cordova_file, web_storage_dump (M), write, sdcard_read, system_read, cookies, clipboard_read (L), read, module, plugin_dir, web_storage, indexeddb (I) |
| Obfuscation | obfuscator_tool, packer, jsfuck, encoded_js_payload (H), js.parse_failed, js.too_large, js.invalid_utf8 (M), escaped_strings, hex_array (L) |
| WebAssembly | remote_module (H), eval_import (H, under dynamic code), disguised, bad_magic, parse_failed (M), module, embedded_module (L), instantiate (I) |
| Correlation | exfiltration (C): reads user data (files, editor text, storage, clipboard, keys, acode.app account) and talks to a data-capture endpoint or a hardcoded bot token |
Prefixes are omitted in the table; full IDs look like shell.exec.
diff --format json returns recommendation, reasons, new_findings,
escalated, removed_findings, new_endpoints, new_required_modules,
new_defined_modules, new_permissions, new_arguments (key and the
arguments it didn't have before), and files (added/removed/changed).
Suggested flow for POST /api/plugin (new) and PUT /api/plugin (update):
- Run the existing
exploreZip/validatePluginchecks, then save the zip to a quarantine path, notdata/plugins/{id}.zip. - Run the scanner in a child process with a timeout. Store the JSON with the
zip's SHA-256,
scanner_version, andrules_version. - Decide:
- New plugin: always goes to admin review, with the Markdown report attached.
- Update:
diff published.zip quarantine.zip. If it passes, promote the zip. Otherwise hold it as pending and email admins.
- Show
scan --summary(orcapabilitiesfrom the JSON) on the plugin page. - When
rules_versionchanges, rescan every published zip.
const { execFile } = require('node:child_process');
function scan(args) {
return new Promise((resolve, reject) => {
execFile('plugin_scanner', args, { timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => {
// Exit code 1 only means the --fail-on threshold was reached.
if (error && error.code !== 1) return reject(error);
resolve(JSON.parse(stdout));
});
});
}
const report = await scan(['scan', quarantinePath, '--json']);
const diff = published ? await scan(['diff', publishedPath, quarantinePath, '--format', 'json']) : null;Run it as an unprivileged user with no network access and a memory limit
(e.g. prlimit --as=1G). It parses untrusted input.
scripts/scan-dir.sh scans every zip in a folder and prints one line per
plugin plus totals. Run it on the server against data/plugins to see the
verdict mix before turning on automatic holds. Don't download from the public
API for this: downloads are counted.
scripts/scan-dir.sh /path/to/acode-app/data/plugins > scan-results.tsvcargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --checkWhen adding a rule:
- Put it in
src/rules.rs. - Give it a stable
key(no minified names) sodiffstays quiet across rebuilds. - Add a positive and a negative test.
- Bump
RULES_VERSIONinsrc/report.rs.
{ "schema_version": 2, "scanner_version": "0.5.0", "rules_version": "2026.11.4", // rescan stored plugins when this changes "plugin": { "id", "name", "version", "main", "entry", "permissions", "dependencies", ... }, "verdict": { "risk": "high", "recommendation": "review", "complete": true, "reasons": ["..."] }, "summary": { "total_findings", "by_severity", "by_category" }, "capabilities": [{ "category", "title", "description", "severity", "rules", "evidence" }], "endpoints": [{ "host", "count", "example", "tags" }], "modules": { "required": ["fs", "terminal"], "defined": ["myapi"] }, "findings": [{ "id", "severity", "category", "confidence", "file", "span", "message", "evidence", "key", // stable across rebuilds; used by diff "occurrences", "examples", "locations" }], "files": [{ "path", "size", "sha256", "kind" }], "errors": [{ "file", "message" }], "stats": { "archive_entries", "installed_files", "bytes_uncompressed", "js_files_parsed", "js_bytes_parsed", "minified_files" } }