mirror of
https://github.com/bee-san/RustScan.git
synced 2026-11-07 09:46:04 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46f982b9b | ||
|
|
521f8bf720 | ||
|
|
ab1e69f1af | ||
|
|
29d85a77df |
No files matched your search
@@ -25,8 +25,11 @@ pub enum ScanOrder {
|
||||
/// - custom will read the ScriptConfig file and the available scripts in the predefined folders
|
||||
#[derive(Deserialize, Debug, ValueEnum, Clone, PartialEq, Eq, Copy)]
|
||||
pub enum ScriptsRequired {
|
||||
#[serde(alias = "none")]
|
||||
None,
|
||||
#[serde(alias = "default")]
|
||||
Default,
|
||||
#[serde(alias = "custom")]
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -526,6 +529,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_config_script_mode(value: &str, expected: ScriptsRequired) {
|
||||
let config: Config = toml::from_str(&format!("scripts = \"{value}\""))
|
||||
.expect("documented script mode must deserialize");
|
||||
assert_eq!(config.scripts, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_scripts_lowercase_none() {
|
||||
assert_config_script_mode("none", ScriptsRequired::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_scripts_lowercase_default() {
|
||||
assert_config_script_mode("default", ScriptsRequired::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_scripts_lowercase_custom() {
|
||||
assert_config_script_mode("custom", ScriptsRequired::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_scripts_legacy_and_unknown_controls() {
|
||||
assert_config_script_mode("None", ScriptsRequired::None);
|
||||
assert_config_script_mode("Default", ScriptsRequired::Default);
|
||||
assert_config_script_mode("Custom", ScriptsRequired::Custom);
|
||||
for invalid in ["disabled", "unknown", ""] {
|
||||
assert!(toml::from_str::<Config>(&format!("scripts = \"{invalid}\"")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_read_accepts_documented_lowercase_script_modes() {
|
||||
for (value, expected) in [
|
||||
("none", ScriptsRequired::None),
|
||||
("default", ScriptsRequired::Default),
|
||||
("custom", ScriptsRequired::Custom),
|
||||
] {
|
||||
let fixture = std::env::temp_dir().join(format!(
|
||||
"rustscan-config-scripts-{}-{value}.toml",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&fixture, format!("scripts = \"{value}\"\n")).unwrap();
|
||||
let config = Config::read(Some(fixture.clone()));
|
||||
std::fs::remove_file(fixture).unwrap();
|
||||
assert_eq!(config.scripts, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_cli() {
|
||||
Opts::command().debug_assert();
|
||||
|
||||
+72
-15
@@ -128,20 +128,7 @@ fn main() {
|
||||
map.entry(socket.ip()).or_default().push(socket.port());
|
||||
}
|
||||
|
||||
for ip in ips {
|
||||
if ports_per_ip.contains_key(&ip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we got here it means the IP was not found within the HashMap, this
|
||||
// means the scan couldn't find any open ports for it.
|
||||
|
||||
let x = format!("Looks like I didn't find any open ports for {:?}. This is usually caused by a high batch size.
|
||||
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
|
||||
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
|
||||
ip,
|
||||
batch_size,
|
||||
"'rustscan -b <batch_size> -a <ip address>'");
|
||||
if let Some(x) = no_open_ports_warning(&ips, &ports_per_ip, &closed_ports_per_ip, batch_size) {
|
||||
warning!(x, opts.greppable, opts.accessible);
|
||||
}
|
||||
|
||||
@@ -224,6 +211,35 @@ fn main() {
|
||||
info!("{}", benchmarks.summary());
|
||||
}
|
||||
|
||||
/// Builds the hint printed when the scan found no open ports.
|
||||
///
|
||||
/// A host without open ports is normal when another host answered (a subnet
|
||||
/// sweep for one port): replies got through at this batch size and timeout,
|
||||
/// so batch size and timeout are unlikely to be the cause. The hint is printed
|
||||
/// once, and only when no host answered with an open or, under `--closed`, a
|
||||
/// closed port.
|
||||
fn no_open_ports_warning(
|
||||
ips: &[IpAddr],
|
||||
ports_per_ip: &HashMap<IpAddr, Vec<u16>>,
|
||||
closed_ports_per_ip: &HashMap<IpAddr, Vec<u16>>,
|
||||
batch_size: usize,
|
||||
) -> Option<String> {
|
||||
if !ports_per_ip.is_empty() || !closed_ports_per_ip.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target = match ips {
|
||||
[ip] => format!("for {ip:?}"),
|
||||
_ => format!("on any of the {} addresses", ips.len()),
|
||||
};
|
||||
Some(format!("Looks like I didn't find any open ports {}. This is usually caused by a high batch size.
|
||||
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
|
||||
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
|
||||
target,
|
||||
batch_size,
|
||||
"'rustscan -b <batch_size> -a <ip address>'"))
|
||||
}
|
||||
|
||||
/// Determines the actual batch size used by the scanner.
|
||||
///
|
||||
/// Unix systems may reduce the requested batch size according to the process
|
||||
@@ -373,7 +389,48 @@ mod tests {
|
||||
use super::effective_batch_size;
|
||||
#[cfg(unix)]
|
||||
use super::{adjust_ulimit_size, infer_batch_size};
|
||||
use super::{print_opening, Opts};
|
||||
use super::{no_open_ports_warning, print_opening, Opts};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
fn hosts(n: u8) -> Vec<IpAddr> {
|
||||
(1..=n).map(|i| IpAddr::from([192, 0, 2, i])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_open_ports_silent_when_another_host_answered() {
|
||||
// Sweeping a subnet for one port: one host has it open, the rest do not.
|
||||
let ips = hosts(254);
|
||||
let ports_per_ip = HashMap::from([(ips[0], vec![22])]);
|
||||
|
||||
assert!(no_open_ports_warning(&ips, &ports_per_ip, &HashMap::new(), 4500).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_open_ports_silent_when_a_host_answered_closed() {
|
||||
// With --closed, a refused connection also proves replies get through.
|
||||
let ips = hosts(254);
|
||||
let closed_ports_per_ip = HashMap::from([(ips[0], vec![443])]);
|
||||
|
||||
assert!(no_open_ports_warning(&ips, &HashMap::new(), &closed_ports_per_ip, 4500).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_open_ports_warns_once_when_nothing_found() {
|
||||
let warning = no_open_ports_warning(&hosts(254), &HashMap::new(), &HashMap::new(), 4500);
|
||||
|
||||
let warning = warning.expect("one hint when nothing was found");
|
||||
assert!(warning.contains("on any of the 254 addresses."));
|
||||
assert!(warning.contains("4500 batch size"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_open_ports_names_the_single_host() {
|
||||
let warning = no_open_ports_warning(&hosts(1), &HashMap::new(), &HashMap::new(), 4500);
|
||||
|
||||
let warning = warning.expect("one hint for the single host");
|
||||
assert!(warning.contains("for 192.0.2.1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
|
||||
+121
-2
@@ -143,9 +143,78 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
|
||||
}
|
||||
}
|
||||
|
||||
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<PathBuf>) -> Vec<ScriptFile> {
|
||||
let mut parsed_scripts: Vec<ScriptFile> = Vec::with_capacity(scripts.len());
|
||||
for script in scripts {
|
||||
@@ -348,8 +417,7 @@ impl ScriptFile {
|
||||
if let Ok(file) = File::open(script) {
|
||||
for mut line in io::BufReader::new(file).lines().skip(1).flatten() {
|
||||
if line.starts_with('#') {
|
||||
line.retain(|c| c != '#');
|
||||
line = line.trim().to_string();
|
||||
line = line[1..].trim().to_string();
|
||||
line.push('\n');
|
||||
lines_buf.push_str(&line);
|
||||
} else {
|
||||
@@ -403,6 +471,21 @@ impl ScriptConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn script_headers_preserve_literal_hash_characters() {
|
||||
let fixture =
|
||||
std::env::temp_dir().join(format!("rustscan-literal-hash-{}.txt", std::process::id()));
|
||||
fs::write(&fixture, "#!/usr/bin/env python3\n#tags = [\"C#\"]\n#call_format = \"fixture {{ip}} {{port}} https://example.invalid/#section\"\n\n").unwrap();
|
||||
let parsed = ScriptFile::new(fixture.clone());
|
||||
fs::remove_file(fixture).unwrap();
|
||||
let parsed = parsed.expect("valid TOML metadata");
|
||||
assert_eq!(parsed.tags, Some(vec!["C#".to_owned()]));
|
||||
assert_eq!(
|
||||
parsed.call_format.as_deref(),
|
||||
Some("fixture {{ip}} {{port}} https://example.invalid/#section")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_parse_scripts() {
|
||||
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
|
||||
@@ -495,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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user