From b46f982b9ba5cee8feb79cfca2208f1809f31fd8 Mon Sep 17 00:00:00 2001 From: QianEric Date: Wed, 4 Nov 2026 17:29:26 +0800 Subject: [PATCH] scripts: report a missing script program before scanning (#965) Scripts are run through `sh -c`, so when the program a `call_format` starts with is missing, the only feedback is whatever the shell prints once the scan has already finished: Open 127.0.0.1:18080 [~] Starting Script(s) [!] Error No such file or directory (os error 2) which neither names the program nor arrives before a long scan has been wasted. Check the first word of every selected script format against PATH while the scripts are initialised, and fail there with the program named. Name resolution follows what the shell would do: PATH entries in order, plus PATHEXT on Windows. Formats that do not start with a bare program name are left alone, since their first word is not something PATH can answer for: - `FOO=bar cmd ...` (leading assignment) - `/opt/custom/nmap ...` (explicit path) - `{{script}} ...` (placeholder filled in at run time) With nmap absent, on the same scan: before: scans, then "[!] Error No such file or directory (os error 2)" after: "[!] Initiating scripts failed! The script `default` runs `nmap`, which is not installed or not in PATH. Install it, or skip scripts with `--scripts none`." With nmap present the scan and the script phase run as before. Implemented with std/target-specific helpers rather than a new dependency: `which` pulls in a newer windows-sys and rewrites ~110 lines of Cargo.lock for a 15-line check. Refs #691 Co-authored-by: QianEric <307361214+qianeric-backup@users.noreply.github.com> --- src/scripts/mod.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index c57d418..154fdaa 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -143,9 +143,78 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result> { } } + verify_script_programs(&scripts_to_run)?; + Ok(scripts_to_run) } +/// Whether `path/program` is something the shell could run. +#[cfg(not(windows))] +fn program_exists_in(dir: &std::path::Path, program: &str) -> bool { + dir.join(program).is_file() +} + +/// Whether `path/program` is something `cmd.exe` could run, which appends +/// entries from `PATHEXT` to a bare name. +#[cfg(windows)] +fn program_exists_in(dir: &std::path::Path, program: &str) -> bool { + if dir.join(program).is_file() { + return true; + } + let extensions = std::env::var("PATHEXT").unwrap_or_default(); + extensions + .split(';') + .filter(|extension| !extension.is_empty()) + .any(|extension| dir.join(format!("{program}{extension}")).is_file()) +} + +fn program_is_available(program: &str) -> bool { + match std::env::var_os("PATH") { + Some(path) => std::env::split_paths(&path).any(|dir| program_exists_in(&dir, program)), + None => false, + } +} + +/// Check that the program each selected script starts with is available. +/// +/// Scripts run through `sh -c`, so a missing binary surfaces only as a bare +/// "Exit code = 127" once the scan has already finished. Report it up front +/// instead, before a long scan has been wasted. +fn verify_script_programs(scripts_to_run: &[ScriptFile]) -> Result<()> { + for script in scripts_to_run { + let call_format = match &script.call_format { + Some(call_format) => call_format, + None => continue, + }; + let program = match call_format.split_whitespace().next() { + Some(program) => program, + None => continue, + }; + // A leading assignment (`FOO=bar nmap ...`) or an absolute path is not + // a bare program name, and `{{...}}` placeholders are only filled in + // when the script actually runs, so leave those formats alone. + if program.contains('=') + || program.contains('/') + || program.contains('$') + || program.contains("{{") + { + continue; + } + if !program_is_available(program) { + return Err(anyhow!( + "The script `{}` runs `{}`, which is not installed or not in PATH. \ + Install it, or skip scripts with `--scripts none`.", + script + .path + .as_ref() + .map_or_else(|| "default".to_string(), |path| path.display().to_string()), + program + )); + } + } + Ok(()) +} + pub fn parse_scripts(scripts: Vec) -> Vec { let mut parsed_scripts: Vec = Vec::with_capacity(scripts.len()); for script in scripts { @@ -509,4 +578,40 @@ mod tests { assert_eq!(script_dir_base, dirs::home_dir().unwrap()); } + + fn script_with_call_format(call_format: &str) -> ScriptFile { + ScriptFile { + path: Some(PathBuf::from("/tmp/script.txt")), + tags: None, + developer: None, + port: None, + ports_separator: None, + call_format: Some(call_format.to_string()), + } + } + + #[test] + fn missing_script_program_is_reported() { + let scripts = vec![script_with_call_format("definitely-not-a-real-binary {{ip}}")]; + let err = verify_script_programs(&scripts).unwrap_err(); + assert!(err.to_string().contains("definitely-not-a-real-binary")); + } + + #[test] + fn available_script_program_is_accepted() { + // `sh` is what the scripts are actually run through, so it is present + // on every platform that can execute them. + let scripts = vec![script_with_call_format("sh -c true {{ip}}")]; + assert!(verify_script_programs(&scripts).is_ok()); + } + + #[test] + fn placeholder_and_assignment_prefixes_are_skipped() { + let scripts = vec![ + script_with_call_format("{{script}} {{ip}} {{port}}"), + script_with_call_format("FOO=bar definitely-not-a-real-binary"), + script_with_call_format("/opt/custom/nmap {{ip}}"), + ]; + assert!(verify_script_programs(&scripts).is_ok()); + } }