Skip to content

About

A acode plugin scanner

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

Repository files navigation

Acode Plugin Scanner

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.

Quick start

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.

Verdict

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.

How it avoids false positives

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 system or a local fetch is not the Acode global.
  • Real Acode surface. Rules are keyed on what Acode exposes: window.* globals from src/lib, Cordova clobbers (system, sdcard, Executor, Terminal, ftp, sftp, iap, CreateServer, cordova.plugin.http) and the modules registered in lib/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 -e get 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/x is not; reading /etc/profile is not persistence; Acode's own /data/data/com.foxdebug.acode… folder is not another app's.
  • Shell helpers are followed. In const sh = c => Executor.execute(c), each sh("…") 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 follows this.run(cmd) methods, getters such as getExecutor(), quoting helpers ('${s.replace(/'/g, …)}', JSON.stringify), functions that build a script from a template, $VAR names assigned earlier in the same script, every branch of a ? "x" : "y", and btoa(…) or a local base64 helper that ends in btoa.
  • Encoded and staged scripts are opened. Base64 piped through base64 -d in 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 is staged_script (H), and the file's multi-line, script-shaped constants are checked as the likely content.
  • Source built at runtime is listed. git clone followed by a build (make, cmake, npm install, cargo build, …), pip install git+…, and npm install github:… are builds_remote_source, naming each repository (for R in a b c; do git clone …/$R loops 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.write to 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 endpoints list. 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 is hardcoded_bot_token (H), even when it is split from the URL.
  • Own storage is not user data. Reading a plugin's own localStorage keys doesn't count toward exfiltration. Dumping all of it (Object.keys, JSON.stringify(localStorage), spreading it, for…in, .key(i)) is web_storage_dump (M) and does.
  • Base64 is decoded. A long base64 string is only flagged when it decodes to JavaScript.

How it avoids being fooled

  • 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.js overwriting main.js
    • duplicate entries
    • absolute paths
    • encrypted entries
    • compression methods other than stored/deflate
    • native binaries detected by magic bytes
    • code under __MACOSX/, .DS_Store or Thumbs.db names, 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.js and loadPlugin.js. main is looked up by exact key, so "./dist/main.js" falls back to main.js, just as on device. The loader then joins the raw main onto the plugin folder, so a main that climbs out with .. runs a file that was never in the zip, and one with backslashes makes the loader fall back to main.js.

  • Alias and constant-string resolution. These all resolve:

    • const w = window, const { exec } = cordova
    • acode.require("fs") stored in a variable
    • window["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 / XMLHttpRequest responses or decoders are tracked through variables and .then(...) chains, into eval, 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.

Rules

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.

JSON report (schema_version: 2)

{
  "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" }
}

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).

Using it on acode.app

Suggested flow for POST /api/plugin (new) and PUT /api/plugin (update):

  1. Run the existing exploreZip/validatePlugin checks, then save the zip to a quarantine path, not data/plugins/{id}.zip.
  2. Run the scanner in a child process with a timeout. Store the JSON with the zip's SHA-256, scanner_version, and rules_version.
  3. 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.
  4. Show scan --summary (or capabilities from the JSON) on the plugin page.
  5. When rules_version changes, 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.

Calibrating against the registry

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.tsv

Development

cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check

When adding a rule:

  • Put it in src/rules.rs.
  • Give it a stable key (no minified names) so diff stays quiet across rebuilds.
  • Add a positive and a negative test.
  • Bump RULES_VERSION in src/report.rs.

About

A acode plugin scanner

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages