Compare commits

..
Author SHA1 Message Date
bee cb1d3ffeae run just 2025-03-04 21:15:56 +00:00
bee 74a9e3e89e Add custom script directory 2025-03-04 21:12:33 +00:00
35 changed files with 1372 additions and 2443 deletions

No files matched your search

+61 -83
View File
@@ -1,22 +1,13 @@
name: CD Pipeline
on:
push:
branches:
- "master"
tags:
- "*"
# Build jobs only need to read the repository. The publish job below is the
# only job that gets write access, and it only runs for tag pushes.
permissions:
contents: read
on: [push]
jobs:
build-nix:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
strategy:
matrix:
type: [ubuntu-x64, ubuntu-x86, armv7, aarch64]
@@ -46,35 +37,40 @@ jobs:
path: target/aarch64-unknown-linux-gnu/release/rustscan
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v0
with:
command: build
target: ${{ matrix.target }}
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- name: Build tar.gz for homebrew installs
if: matrix.type == 'ubuntu-x64'
run: |
tar czf ${{ matrix.name }}.tar.gz -C target/x86_64-unknown-linux-musl/release rustscan
- uses: actions/upload-artifact@v4
with:
executable-name: rustscan
target: ${{ matrix.target }}
archive-name: ${{ matrix.name }}
changes-file: ""
name: ${{ matrix.name }}
path: ${{ matrix.path }}
- uses: actions/upload-artifact@v4
if: matrix.type == 'ubuntu-x64'
with:
name: ${{ matrix.name }}.tar.gz
path: ${{ matrix.name }}.tar.gz
build-deb:
needs: [build-nix]
runs-on: ubuntu-latest
env:
IN_PIPELINE: true
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Install cargo-deb
run: cargo install -f cargo-deb
- uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
- uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: musl-tools # provides musl-gcc
version: 1.0
@@ -83,7 +79,7 @@ jobs:
- name: Deb Build
run: cargo deb --target=x86_64-unknown-linux-musl
- name: Upload Deb Artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: rustscan.deb
path: ./target/x86_64-unknown-linux-musl/debian/*
@@ -92,54 +88,65 @@ jobs:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v0
with:
command: build
target: x86_64-apple-darwin
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- name: Build tar.gz for homebrew installs
run: |
tar czf x86_64-macos-rustscan.tar.gz -C target/x86_64-apple-darwin/release rustscan
- uses: actions/upload-artifact@v4
with:
executable-name: rustscan
target: x86_64-apple-darwin
archive-name: x86_64-macos-rustscan
changes-file: ""
name: x86_64-macos-rustscan
path: target/x86_64-apple-darwin/release/rustscan
- uses: actions/upload-artifact@v4
with:
name: x86_64-macos-rustscan.tar.gz
path: x86_64-macos-rustscan.tar.gz
build-macos-aarch64:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v0
with:
command: build
target: aarch64-apple-darwin
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- name: Build tar.gz for homebrew installs
run: |
tar czf aarch64-macos-rustscan.tar.gz -C target/aarch64-apple-darwin/release rustscan
- uses: actions/upload-artifact@v4
with:
executable-name: rustscan
target: aarch64-apple-darwin
archive-name: aarch64-macos-rustscan
changes-file: ""
name: aarch64-macos-rustscan
path: target/aarch64-apple-darwin/release/rustscan
- uses: actions/upload-artifact@v4
with:
name: aarch64-macos-rustscan.tar.gz
path: aarch64-macos-rustscan.tar.gz
build-windows:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
strategy:
matrix:
type: [windows-x64, windows-x86]
@@ -147,55 +154,26 @@ jobs:
- type: windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
name: x86_64-windows-rustscan
name: x86_64-windows-rustscan.exe
path: target\x86_64-pc-windows-msvc\release\rustscan.exe
- type: windows-x86
os: windows-latest
target: i686-pc-windows-msvc
name: x86-windows-rustscan
name: x86-windows-rustscan.exe
path: target\i686-pc-windows-msvc\release\rustscan.exe
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v0
with:
command: build
target: ${{ matrix.target }}
target: ${{ matrix.target }}
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- uses: actions/upload-artifact@v4
with:
executable-name: rustscan
target: ${{ matrix.target }}
archive-name: ${{ matrix.name }}
changes-file: ""
# Runs only for tag pushes (tags can only be created by maintainers), and
# only after every build job succeeded, so a release never ships partially.
publish:
if: github.ref_type == 'tag'
needs:
- build-nix
- build-deb
- build-macos
- build-macos-aarch64
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write # create the release / upload release assets
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
merge-multiple: true
- name: Publish artifacts to GitHub release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
fail_on_unmatched_files: true
files: |
*.deb
*.sha256
*.tar.gz
*.zip
name: ${{ matrix.name }}
path: ${{ matrix.path }}
+19
View File
@@ -0,0 +1,19 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Enable auto-merge for Dependabot PRs
run: |
gh pr review --approve "$PR_URL"
gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+21
View File
@@ -0,0 +1,21 @@
name: Check Markdown links
on:
push:
branches:
- master
paths-ignore:
- '.github/**'
schedule:
# Run everyday at 9:00 AM (See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07)
- cron: "0 9 * * *"
jobs:
markdown-link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: 'yes'
config-file: './.github/workflows/mlc_config.json'
+8 -19
View File
@@ -1,45 +1,34 @@
name: Test
on:
pull_request:
branches: [master]
push:
branches:
- master
permissions:
contents: read
jobs:
test:
name: Test Suite
strategy:
matrix:
# ubuntu-24.04-arm is the GitHub-hosted arm64 Linux runner;
# macos-latest already runs on Apple Silicon (arm64).
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [stable]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.rust }}
components: clippy, rustfmt
# Caching is handled by Swatinem/rust-cache below.
cache: false
# Keep RUSTFLAGS unset; `just test` already denies Clippy warnings.
rustflags: ""
override: true
- uses: taiki-e/install-action@nextest
- uses: Swatinem/rust-cache@v2
- name: Install Just
uses: extractions/setup-just@v4
uses: extractions/setup-just@v2
- name: Run tests
- name: Run just
run: just test
+5
View File
@@ -17,7 +17,12 @@ jobs:
script:
- cargo build
- cargo test
- "ulimit -n 5000; cargo test timelimits:: -- --ignored --test-threads=1 --show-output"
- cargo fmt -- --check
after_failure:
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
- chmod +x send.sh
- "./send.sh failure $WEBHOOK_URL"
after_success: |
if [ -n "${GITHUB_TOKEN}" ]; then
[ "${TRAVIS_BRANCH} " = master ] &&
Generated
+214 -533
View File
File diff suppressed because it is too large. Load diff
+16 -15
View File
@@ -17,32 +17,33 @@ exclude = [
]
[dependencies]
clap = { version = "4.6.1", features = ["derive", "wrap_help"] }
colored = "3.1.1"
async-std = "1.13.2"
clap = { version = "4.5.31", features = ["derive", "wrap_help"] }
colored = "3.0.0"
async-std = "1.13.0"
futures = "0.3"
rlimit = "0.11.0"
log = "0.4.32"
env_logger = "0.11.10"
anstream = "=1.0.0"
dirs = "7.0.0"
rlimit = "0.10.2"
log = "0.4.25"
env_logger = "0.11.6"
anstream = "=0.6.18"
dirs = "6.0.0"
gcd = "2.0.1"
rand = "0.10.1"
rand = "0.9.0"
colorful = "0.3.2"
ansi_term = "0.12.1"
toml = "1.1.2"
toml = "0.8.20"
serde = "1.0.124"
serde_derive = "1.0.116"
cidr-utils = "0.7.1"
itertools = "0.15.0"
cidr-utils = "0.6.1"
itertools = "0.14.0"
hickory-resolver = { version = "0.24.3", features = ["dns-over-rustls"] }
anyhow = "1.0.40"
text_placeholder = { version = "0.5", features = ["struct_context"] }
once_cell = "1.21.4"
once_cell = "1.20.3"
[dev-dependencies]
parameterized = "2.0.0"
criterion = { version = "0.8", features = ["html_reports"] }
wait-timeout = "0.2"
criterion = { version = "0.5", features = ["html_reports"] }
[package.metadata.deb]
depends = "$auto, nmap"
@@ -62,5 +63,5 @@ path = "src/main.rs"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] }
[[bench]]
name = "benchmark_helpers"
name = "benchmark_portscan"
harness = false
+3 -5
View File
@@ -40,7 +40,7 @@ MacOS:
Arch:
```
pacman -S rustscan
yay rustscan
```
# ✨ Features
@@ -55,14 +55,12 @@ Arch:
| <!--Installation Guide--> | <!--Documentation--> | <!--Discord--> |
| :----------------------------------------: | :------------------------------------: | :-------------------------: |
| :book: [Installation Guide][toc-install] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
| | :penguin: [ARM Support](docs/arm-support.md) | |
| :book: [Installation Guide][links-table-1] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
## 🙋 Table of Contents
- 📖 [Installation Guide][toc-install]
- 🐋 [Docker Usage][toc-docker-usage]
- 🐧 [ARM Support](docs/arm-support.md)
- 🦜 [Discord][discord]
- 🤸 [Usage][usage-1]
@@ -211,7 +209,7 @@ This project follows the [all-contributors](https://github.com/all-contributors/
[speed-3]: https://github.com/RustScan/RustScan/wiki/Increasing-Speed-&-Accuracy "Increasing Speed & Accuracy"
[toc-community]: https://github.com/RustScan/RustScan#-community "Community"
[links-table-1]: https://github.com/RustScan/RustScan#-full-installation-guide "Full installation guide"
[links-table-2]: https://github.com/bee-san/RustScan/wiki "Rustscan"
[links-table-2]: https://rustscan.github.io/RustScan/ "Rustscan"
[discord]: http://discord.skerritt.blog "Discord blog"
[toc-install]: https://github.com/RustScan/RustScan/wiki/Installation-Guide "Installation Guide Wiki"
[toc-docker-usage]: https://github.com/RustScan/RustScan/wiki/Installation-Guide#docker- "Docker Installation Guide Wiki"
-89
View File
@@ -1,89 +0,0 @@
use criterion::{criterion_group, criterion_main, Criterion};
use rustscan::generated::get_parsed_data;
use rustscan::input::{Opts, PortRanges, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::build_udp_payload_lookup;
use std::collections::BTreeMap;
use std::hint::black_box;
use std::net::IpAddr;
use std::time::Duration;
fn bench_address() {
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
}
fn bench_port_strategy() {
let range = PortRanges(vec![(1, 1_000)]);
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
}
fn bench_address_parsing() {
let opts = Opts {
addresses: vec![
"127.0.0.1".to_owned(),
"10.2.0.1".to_owned(),
"192.168.0.0/24".to_owned(),
],
exclude_addresses: Some(vec![
"10.0.0.0/8".to_owned(),
"172.16.0.0/12".to_owned(),
"192.168.0.0/16".to_owned(),
"172.16.0.1".to_owned(),
]),
..Default::default()
};
let _ips = rustscan::address::parse_addresses(&opts);
}
// Replicates the old UDP payload selection behavior:
// scan the whole UDP payload map and find the last payload whose port list contains `port`.
fn old_payload_for_port(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>, port: u16) -> &'static [u8] {
let mut payload: &'static [u8] = b"";
for (ports, value) in udp_map.iter() {
if ports.contains(&port) {
payload = value.as_slice();
}
}
payload
}
fn criterion_benchmark(c: &mut Criterion) {
// Benching helper functions
c.bench_function("parse address", |b| b.iter(bench_address));
c.bench_function("port strategy", |b| b.iter(bench_port_strategy));
let mut address_group = c.benchmark_group("address parsing");
address_group.measurement_time(Duration::from_secs(10));
address_group.bench_function("parse addresses with exclusions", |b| {
b.iter(bench_address_parsing)
});
address_group.finish();
// UDP payload lookup micro-benchmark: compares the old linear scan of the
// payload map with the precomputed port -> payload lookup. No sockets.
let udp_map = get_parsed_data();
let lookup = build_udp_payload_lookup(udp_map);
let ports: Vec<u16> = (1..=4096).collect();
c.bench_function("udp payload lookup/old scan map 1..4096", |b| {
b.iter(|| {
for &p in ports.iter() {
let payload = old_payload_for_port(black_box(udp_map), black_box(p));
black_box(payload);
}
})
});
c.bench_function("udp payload lookup/new hashmap 1..4096", |b| {
b.iter(|| {
for &p in ports.iter() {
let payload = lookup.get(&p).copied().unwrap_or(b"");
black_box(payload);
}
})
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
+80
View File
@@ -0,0 +1,80 @@
use async_std::task::block_on;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use rustscan::input::{PortRange, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::Scanner;
use std::net::IpAddr;
use std::time::Duration;
fn portscan_tcp(scanner: &Scanner) {
let _scan_result = block_on(scanner.run());
}
fn portscan_udp(scanner: &Scanner) {
let _scan_result = block_on(scanner.run());
}
fn bench_address() {
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
}
fn bench_port_strategy() {
let range = PortRange {
start: 1,
end: 1_000,
};
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
}
fn criterion_benchmark(c: &mut Criterion) {
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy_tcp = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
let strategy_udp = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
let scanner_tcp = Scanner::new(
&addrs,
10,
Duration::from_millis(10),
1,
false,
strategy_tcp,
true,
vec![],
false,
);
c.bench_function("portscan tcp", |b| {
b.iter(|| portscan_tcp(black_box(&scanner_tcp)))
});
let scanner_udp = Scanner::new(
&addrs,
10,
Duration::from_millis(10),
1,
false,
strategy_udp,
true,
vec![],
true,
);
let mut udp_group = c.benchmark_group("portscan udp");
udp_group.measurement_time(Duration::from_secs(20));
udp_group.bench_function("portscan udp", |b| {
b.iter(|| portscan_udp(black_box(&scanner_udp)))
});
udp_group.finish();
// Benching helper functions
c.bench_function("parse address", |b| b.iter(bench_address));
c.bench_function("port strategy", |b| b.iter(bench_port_strategy));
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
+19 -91
View File
@@ -25,10 +25,6 @@ pub fn main() {
let mut curr = String::new();
for line in data.trim().split('\n') {
// Strip a trailing carriage return so CRLF checkouts parse
// identically to LF ones (otherwise `\r` pollutes port tokens and
// blank lines stop looking blank).
let line = line.strip_suffix('\r').unwrap_or(line);
if line.contains('#') || line.is_empty() {
continue;
}
@@ -145,7 +141,7 @@ fn ports_v(fp_map: &BTreeMap<i32, String>) -> BTreeMap<i32, Vec<u16>> {
} else if !segment.is_empty() {
match segment.parse::<u16>() {
Ok(port) => port_list.push(port),
Err(_) => println!("Error parsing port: {segment}"),
Err(_) => println!("Error parsing port: {}", segment),
}
}
}
@@ -171,43 +167,38 @@ fn payloads_v(fp_map: &BTreeMap<i32, String>) -> BTreeMap<i32, Vec<u8>> {
let mut payb_linenr: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
for (&line_nr, data) in fp_map {
if data.contains('"') {
let start = data.find('"').expect("payload opening \" not found");
// Pass from the opening quote: the decoder pairs quotes itself
// to split multi-line segments.
payb_linenr.insert(line_nr, parser(data[start..].trim()));
if data.contains('\"') {
let start = data.find('\"').expect("payload opening \" not found");
let payloads = &data[start + 1..];
payb_linenr.insert(line_nr, parser(payloads.trim()));
}
}
payb_linenr
}
/// Converts a quoted Nmap payload block to bytes.
/// Converts a hexadecimal string to a Vec<u8>
///
/// # Arguments
///
/// * `payload` - The raw block text starting at its first opening quote:
/// one or more `"..."` segments separated by whitespace
/// * `payload` - A string slice containing the hexadecimal payload
///
/// # Returns
///
/// The decoded probe bytes: segments are decoded with [`decode_segment`]
/// and concatenated with no separators, so multi-line probes reassemble
/// exactly.
/// A vector of bytes representing the decoded payload
fn parser(payload: &str) -> Vec<u8> {
let payload = payload.trim_matches('"');
let mut tmp_str = String::new();
let mut bytes: Vec<u8> = Vec::new();
let mut rest = payload.trim();
while let Some(open) = rest.find('"') {
let after_open = &rest[open + 1..];
match split_segment(after_open) {
Some((segment, remainder)) => {
decode_segment(segment, &mut bytes);
rest = remainder;
}
None => {
// Unterminated trailing quote: decode what's left literally.
decode_segment(after_open, &mut bytes);
break;
for (idx, char) in payload.chars().enumerate() {
if char == '\\' && payload.chars().nth(idx + 1) == Some('x') {
continue;
} else if char.is_ascii_hexdigit() {
tmp_str.push(char);
if tmp_str.len() == 2 {
bytes.push(u8::from_str_radix(&tmp_str, 16).unwrap());
tmp_str.clear();
}
}
}
@@ -215,69 +206,6 @@ fn parser(payload: &str) -> Vec<u8> {
bytes
}
/// Splits off the first `"..."` segment: `text` must start just after an
/// opening quote. Escaped quotes (`\"`) do not terminate the segment.
///
/// Returns the segment body and the remainder after the closing quote.
fn split_segment(text: &str) -> Option<(&str, &str)> {
let mut escaped = false;
for (i, c) in text.char_indices() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == '"' {
return Some((&text[..i], &text[i + 1..]));
}
}
None
}
/// Decodes one quoted Nmap payload segment in C-style escape format:
/// `\xNN` becomes a byte, common single-char escapes (`\\`, `\"`,
/// `\0`, `\n`, `\r`, `\t`) decode to their value, and every other
/// character contributes its literal bytes.
///
/// Preserving literal text matters: several probes embed plain-text
/// protocol words (the SNMP `public` community string, NetBIOS names,
/// LDAP `objectClass`, SSDP headers). The previous hexdigits-only
/// decoding mangled them into wrong bytes, so agents never answered.
fn decode_segment(segment: &str, bytes: &mut Vec<u8>) {
let mut chars = segment.chars();
while let Some(c) = chars.next() {
if c != '\\' {
let mut buf = [0; 4];
bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
continue;
}
match chars.next() {
Some('x') => {
let hi = chars.next().unwrap_or('0');
let lo = chars.next().unwrap_or('0');
let hex: String = [hi, lo].iter().collect();
bytes.push(u8::from_str_radix(&hex, 16).unwrap_or(0));
}
Some('0') => bytes.push(0),
Some('n') => bytes.push(b'\n'),
Some('r') => bytes.push(b'\r'),
Some('t') => bytes.push(b'\t'),
Some('\\') => bytes.push(b'\\'),
Some('"') => bytes.push(b'"'),
// Unknown escape: keep the character literally, matching Nmap,
// which passes unrecognized escapes through untouched.
Some(other) => {
let mut buf = [0; 4];
bytes.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
None => bytes.push(b'\\'),
}
}
}
/// Combines the ports BTreeMap and the Payloads BTreeMap
///
/// # Arguments
+4 -4
View File
@@ -1,5 +1,5 @@
# Dockerfile for RustScan development environment
# Provides a containerized setup with Rust and development tools
FROM rust
# Install rustfmt for code formatting and clippy for linting.
RUN rustup component add rustfmt clippy
# Install nmap first.
RUN apt-get update -qy && apt-get install -qy nmap
# Then install rustfmt and clippy for cargo.
RUN rustup component add rustfmt clippy
+2 -7
View File
@@ -54,10 +54,10 @@ You can now modify RustScan files with your favorite editor, once you want to co
root@container:/rustscan# cargo build
```
You can check the command-line interface without starting a scan:
You are now ready to use RustScan:
```bash
root@container:/rustscan# cargo run -- --help
root@container:/rustscan# cargo run -- -b 2000 -t 5000 -a 127.0.0.1
```
You can also format, lint with `clippy` and test the code with the following commands:
@@ -67,8 +67,3 @@ root@container:/rustscan# cargo fmt
root@container:/rustscan# cargo clippy
root@container:/rustscan# cargo test
```
Automated tests and benchmarks must run without network traffic or external script
execution. Keep coverage focused on parsing, configuration, port ordering, socket
address iteration, and timing helpers. Do not add tests that run TCP/UDP scans
(including localhost), resolve hostnames, or launch Nmap or other scripts.
-38
View File
@@ -1,38 +0,0 @@
# ARM support
This page covers which ARM targets RustScan's CI builds and tests, and how to build ARM binaries yourself.
## What CI covers
| Target | Built by | Tested by |
| --- | --- | --- |
| `aarch64-unknown-linux-gnu` (64-bit ARM Linux) | `build-nix` job in [build.yml](../.github/workflows/build.yml) | `Test Suite (ubuntu-24.04-arm)` in [test.yml](../.github/workflows/test.yml) |
| `armv7-unknown-linux-gnueabihf` (32-bit ARM Linux) | `build-nix` job in [build.yml](../.github/workflows/build.yml) | not tested in CI |
| `aarch64-apple-darwin` (Apple Silicon macOS) | `build-macos-aarch64` job in [build.yml](../.github/workflows/build.yml) | `Test Suite (macos-latest)` in [test.yml](../.github/workflows/test.yml) |
- **Builds** use [`houseabsolute/actions-rust-cross`](https://github.com/houseabsolute/actions-rust-cross). It cross-compiles the Linux ARM targets with [`cross`](https://github.com/cross-rs/cross) on x86_64 runners.
- **Tests** run natively on GitHub-hosted arm64 runners: `ubuntu-24.04-arm` for Linux, and `macos-latest`, which runs on Apple Silicon. They run the same `just test` recipe as every other platform.
## Building ARM binaries locally
The simplest option is `cross`, which runs the build in a container that already has the right toolchain and linker. It needs Docker or Podman.
```sh
cargo install cross
cross build --locked --release --target aarch64-unknown-linux-gnu
# or, for 32-bit ARM:
cross build --locked --release --target armv7-unknown-linux-gnueabihf
```
The binary ends up at `target/<target>/release/rustscan`.
On an ARM machine (for example a Raspberry Pi running a 64-bit OS, or an Apple Silicon Mac) you don't need `cross`; a normal native build works:
```sh
cargo build --locked --release
```
## Troubleshooting
- **`error[E0463]: can't find crate for std`, or linker errors:** you're cross-compiling with plain `cargo`. Either use `cross`, or install the target (`rustup target add <target>`) along with a matching cross linker.
- **`cross` can't pull its image:** check that Docker or Podman is running and that your user can reach it.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/perl
#tags = ["core_approved", "example",]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "perl {{script}} {{ip}} {{port}}"
# Sriptfile parser stops at the first blank line with parsing.
# This script will run itself as an argument with the system installed perl interpreter, ports will be concatenated with "," .
# Unused field: trigger_port = "80"
# get total arg passed to this script
my $total = $#ARGV + 1;
my $counter = 1;
# get script name
my $scriptname = $0;
print "Total args passed to $scriptname : $total\n";
# Use loop to print all args stored in an array called @ARGV
foreach my $a(@ARGV) {
print "Arg # $counter : $a\n";
$counter++;
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/python3
#tags = ["core_approved", "example",]
#developer = [ "example", "https://example.org" ]
#trigger_port = "80"
#call_format = "python3 {{script}} {{ip}} {{port}}"
# Sriptfile parser stops at the first blank line with parsing.
# This script will run itself as an argument with the system installed python interpreter, only scanning port 80.
# Unused filed: ports_separator = ","
import sys
print('Python script ran with arguments', str(sys.argv))
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
#tags = ["core_approved", "example",]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "bash {{script}} {{ip}} {{port}}"
# Sriptfile parser stops at the first blank line with parsing.
# This script will run itself as an argument with the system installed bash interpreter, scanning all ports concatenated with "," .
# Unused filed: trigger_port = "80"
# print all arguments passed to the script
echo $@
+5 -3
View File
@@ -1,7 +1,9 @@
Metadata-only test fixture; not executable.
#!intentional_blank_line
#tags = ["core_approved", "example"]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "fixture {{ip}} {{port}}"
#call_format = "nmap -vvv -p {{port}} {{ip}}"
The parser reads only the metadata above. This file contains no script body.
# Scriptfile parser stops at the first blank line with parsing.
# This script will run the system installed nmap, ports will be concatenated with "," .
# Unused field: trigger_port = "80"
@@ -1,7 +1,7 @@
Metadata-only test fixture; not executable.
#!intentional_blank_line
#tags = "core_approved"
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "fixture {{ip}} {{port}}"
#call_format = "nmap -vvv -p {{port}} {{ip}}"
# tags has to be an array, thus this file won't be parsed
# tags has to be an array, thus this file won't be parsed
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
127.0.0.1
google.com
example.com
66666666666666.666666666666666666.66666666666666666.6666666666
....
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.radiatorFixtures
+1 -1
View File
@@ -1,5 +1,5 @@
test:
cargo test
cargo nextest run
cargo clippy -- --deny warnings
cargo clippy --tests -- --deny warnings
cargo fmt --check
+113 -191
View File
@@ -6,7 +6,7 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::str::FromStr;
use cidr_utils::cidr::{IpCidr, IpInet};
use cidr_utils::cidr::IpCidr;
use hickory_resolver::{
config::{NameServerConfig, Protocol, ResolverConfig, ResolverOpts},
Resolver,
@@ -69,11 +69,17 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
}
}
let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver);
// Finally, craft a list of addresses to be excluded from the scan.
let mut excluded_ips: BTreeSet<IpAddr> = BTreeSet::new();
if let Some(exclude_addresses) = &input.exclude_addresses {
for addr in exclude_addresses {
excluded_ips.extend(parse_address(addr, &backup_resolver));
}
}
// Remove duplicated/excluded IPs.
let mut seen = BTreeSet::new();
ips.retain(|ip| seen.insert(*ip) && !excluded_cidrs.iter().any(|cidr| cidr.contains(ip)));
ips.retain(|ip| seen.insert(*ip) && !excluded_ips.contains(ip));
ips
}
@@ -92,21 +98,16 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
/// ```
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
if let Ok(addr) = IpAddr::from_str(address) {
// `address` is an IP string
vec![addr]
} else if let Ok(net_addr) = IpInet::from_str(address) {
// `address` is a CIDR string
net_addr.network().into_iter().addresses().collect()
} else {
// `address` is a hostname or DNS name
// attempt default DNS lookup
match format!("{address}:80").to_socket_addrs() {
Ok(mut iter) => vec![iter.next().unwrap().ip()],
// default lookup didn't work, so try again with the dedicated resolver
Err(_) => resolve_ips_from_host(address, resolver),
}
}
IpCidr::from_str(address)
.map(|cidr| cidr.iter().map(|c| c.address()).collect())
.ok()
.or_else(|| {
format!("{}:80", &address)
.to_socket_addrs()
.ok()
.map(|mut iter| vec![iter.next().unwrap().ip()])
})
.unwrap_or_else(|| resolve_ips_from_host(address, resolver))
}
/// Uses DNS to get the IPS associated with host
@@ -124,46 +125,6 @@ fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr
ips
}
/// Parses excluded networks from a list of addresses.
///
/// This function handles three types of inputs:
/// 1. CIDR notation (e.g. "192.168.0.0/24")
/// 2. Single IP addresses (e.g. "192.168.0.1")
/// 3. Hostnames that need to be resolved (e.g. "example.com")
///
/// ```rust
/// # use rustscan::address::parse_excluded_networks;
/// # use hickory_resolver::Resolver;
/// let resolver = Resolver::default().unwrap();
/// let excluded = parse_excluded_networks(&Some(vec!["192.168.0.0/24".to_owned()]), &resolver);
/// ```
pub fn parse_excluded_networks(
exclude_addresses: &Option<Vec<String>>,
resolver: &Resolver,
) -> Vec<IpCidr> {
exclude_addresses
.iter()
.flatten()
.flat_map(|addr| parse_single_excluded_address(addr, resolver))
.collect()
}
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec<IpCidr> {
if let Ok(cidr) = IpCidr::from_str(addr) {
return vec![cidr];
}
if let Ok(ip) = IpAddr::from_str(addr) {
return vec![IpCidr::new_host(ip)];
}
resolve_ips_from_host(addr, resolver)
.into_iter()
.map(IpCidr::new_host)
.collect()
}
/// Derive a DNS resolver.
///
/// 1. if the `resolver` parameter has been set:
@@ -191,56 +152,17 @@ fn get_resolver(resolver: &Option<String>) -> Resolver {
Protocol::Udp,
));
}
Resolver::new(config, resolver_opts()).unwrap()
Resolver::new(config, ResolverOpts::default()).unwrap()
}
None => match system_resolver() {
None => match Resolver::from_system_conf() {
Ok(resolver) => resolver,
Err(_) => Resolver::new(ResolverConfig::cloudflare_tls(), resolver_opts()).unwrap(),
Err(_) => {
Resolver::new(ResolverConfig::cloudflare_tls(), ResolverOpts::default()).unwrap()
}
},
}
}
/// `true` on Windows when the `SystemRoot` environment variable is unset.
///
/// hickory-resolver locates the hosts file via
/// `std::env::var_os("SystemRoot").expect(...)`, which panics when the
/// variable is missing. Processes spawned with a minimal environment
/// (services, scheduled tasks, WMI) can lack `SystemRoot`, and the panic is
/// fatal under this crate's `panic = "abort"` release profile.
fn windows_system_root_missing() -> bool {
cfg!(windows) && std::env::var_os("SystemRoot").is_none()
}
/// Derives a resolver from the system configuration, e.g. `/etc/resolv.conf`
/// on *nix or the registry on Windows.
///
/// Returns an error without touching the system configuration when doing so
/// would panic inside hickory-resolver (see [`windows_system_root_missing`]).
fn system_resolver() -> std::io::Result<Resolver> {
if windows_system_root_missing() {
debug!("SystemRoot is not set; skipping system resolver configuration");
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"SystemRoot environment variable is not set",
));
}
Resolver::from_system_conf()
}
/// Resolver options that are safe to use in the current environment.
///
/// hickory-resolver eagerly loads the hosts file when `use_hosts_file` is set
/// (the default), which hits the same missing-`SystemRoot` panic described in
/// [`windows_system_root_missing`]; disable it in that case.
fn resolver_opts() -> ResolverOpts {
let mut opts = ResolverOpts::default();
if windows_system_root_missing() {
opts.use_hosts_file = false;
}
opts
}
/// Parses and input file of IPs for use in DNS resolution.
fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
let ips = fs::read_to_string(path)?
@@ -275,8 +197,8 @@ fn read_ips_from_file(
#[cfg(test)]
mod tests {
use super::{parse_addresses, Opts};
use std::net::{IpAddr, Ipv4Addr};
use super::{get_resolver, parse_addresses, Opts};
use std::net::Ipv4Addr;
#[test]
fn parse_correct_addresses() {
@@ -357,6 +279,81 @@ mod tests {
);
}
#[test]
fn parse_correct_host_addresses() {
let opts = Opts {
addresses: vec!["google.com".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 1);
}
#[test]
fn parse_correct_and_incorrect_addresses() {
let opts = Opts {
addresses: vec!["127.0.0.1".to_owned(), "im_wrong".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips, [Ipv4Addr::new(127, 0, 0, 1),]);
}
#[test]
fn parse_incorrect_addresses() {
let opts = Opts {
addresses: vec!["im_wrong".to_owned(), "300.10.1.1".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert!(ips.is_empty());
}
#[test]
fn parse_hosts_file_and_incorrect_hosts() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let opts = Opts {
addresses: vec!["fixtures/hosts.txt".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 3);
}
#[test]
fn parse_empty_hosts_file() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let opts = Opts {
addresses: vec!["fixtures/empty_hosts.txt".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 0);
}
#[test]
fn parse_naughty_host_file() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let opts = Opts {
addresses: vec!["fixtures/naughty_string.txt".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 0);
}
#[test]
fn parse_duplicate_cidrs() {
let opts = Opts {
@@ -370,102 +367,27 @@ mod tests {
}
#[test]
fn parse_overspecific_cidr() {
// a canonical CIDR string has 0 in all host bits, but we want to treat any CIDR-like string as CIDR
let opts = Opts {
addresses: vec!["192.128.1.1/24".to_owned()],
..Default::default()
};
fn resolver_default_cloudflare() {
let opts = Opts::default();
let ips = parse_addresses(&opts);
let resolver = get_resolver(&opts.resolver);
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
assert_eq!(ips.len(), 256);
assert!(opts.resolver.is_none());
assert!(lookup.iter().next().is_some());
}
#[test]
fn parse_non_canonical_cidr_mid_block() {
// 192.168.1.13/29: .13 = 0000 1101, mask clears last 3 bits → .8 = 0000 1000
// network is 192.168.1.8/29, spanning .8 through .15
fn resolver_args_google_dns() {
// https://developers.google.com/speed/public-dns
let opts = Opts {
addresses: vec!["192.168.1.13/29".to_owned()],
resolver: Some("8.8.8.8,8.8.4.4".to_owned()),
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 1, 8),
Ipv4Addr::new(192, 168, 1, 9),
Ipv4Addr::new(192, 168, 1, 10),
Ipv4Addr::new(192, 168, 1, 11),
Ipv4Addr::new(192, 168, 1, 12),
Ipv4Addr::new(192, 168, 1, 13),
Ipv4Addr::new(192, 168, 1, 14),
Ipv4Addr::new(192, 168, 1, 15),
]
);
}
#[test]
fn parse_non_canonical_cidr_last_in_block() {
// 192.168.1.15/29: last address in the block, should still resolve to same .8–.15 network
let opts = Opts {
addresses: vec!["192.168.1.15/29".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 1, 8),
Ipv4Addr::new(192, 168, 1, 9),
Ipv4Addr::new(192, 168, 1, 10),
Ipv4Addr::new(192, 168, 1, 11),
Ipv4Addr::new(192, 168, 1, 12),
Ipv4Addr::new(192, 168, 1, 13),
Ipv4Addr::new(192, 168, 1, 14),
Ipv4Addr::new(192, 168, 1, 15),
]
);
}
let resolver = get_resolver(&opts.resolver);
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
#[test]
fn parse_non_canonical_cidr_crosses_third_octet() {
// 192.168.1.5/23: host bits span into the third octet
// .1.5 in 23-bit context → network is 192.168.0.0/23, spanning .0.0 through .1.255 (512 addresses)
let opts = Opts {
addresses: vec!["192.168.1.5/23".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips.first(),
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0)))
);
assert_eq!(
ips.last(),
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255)))
);
assert_eq!(ips.len(), 512);
}
#[test]
fn parse_non_canonical_cidr_slash30() {
// 10.0.0.7/30: .7 = 0000 0111, mask clears last 2 bits → .4 = 0000 0100
// network is 10.0.0.4/30, spanning .4 through .7
let opts = Opts {
addresses: vec!["10.0.0.7/30".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(10, 0, 0, 4),
Ipv4Addr::new(10, 0, 0, 5),
Ipv4Addr::new(10, 0, 0, 6),
Ipv4Addr::new(10, 0, 0, 7),
]
);
assert!(lookup.iter().next().is_some());
}
}
+7 -5
View File
@@ -42,11 +42,13 @@ impl Benchmark {
let mut summary = String::from("\nRustScan Benchmark Summary");
for timer in &self.named_timers {
if let Some(start) = timer.start {
if let Some(end) = timer.end {
let runtime_secs = end.saturating_duration_since(start).as_secs_f32();
summary.push_str(&format!("\n{0: <10} | {1: <10}s", timer.name, runtime_secs));
}
if timer.start.is_some() && timer.end.is_some() {
let runtime_secs = timer
.end
.unwrap()
.saturating_duration_since(timer.start.unwrap())
.as_secs_f32();
summary.push_str(&format!("\n{0: <10} | {1: <10}s", timer.name, runtime_secs));
}
}
summary
+58 -455
View File
@@ -1,9 +1,7 @@
//! Provides a means to read, parse and hold configuration options for scans.
use clap::parser::ValueSource;
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
use clap::{Parser, ValueEnum};
use serde_derive::Deserialize;
use std::collections::HashSet;
use std::ffi::OsString;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
@@ -30,94 +28,35 @@ pub enum ScriptsRequired {
Custom,
}
/// The port ranges to scan, as inclusive `(start, end)` pairs.
///
/// Parsed from `-r/--range` (e.g. `1-500,1000-2500`) and from the `range`
/// key of the configuration file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortRanges(pub Vec<(u16, u16)>);
/// Accepted spellings of `range` in the configuration file.
#[derive(Deserialize)]
#[serde(untagged)]
enum PortRangesConfig {
/// `range = { start = 1, end = 1000 }`: the format used before multiple
/// ranges were supported, kept so existing configuration files still work.
Single { start: u16, end: u16 },
/// `range = "1-500,1000-2500"`: the same syntax as `--range`.
Text(String),
/// `range = [[1, 500], [1000, 2500]]`.
Pairs(Vec<(u16, u16)>),
}
impl<'de> serde::Deserialize<'de> for PortRanges {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let pairs = match PortRangesConfig::deserialize(deserializer)? {
PortRangesConfig::Single { start, end } => vec![(start, end)],
PortRangesConfig::Text(text) => {
return parse_ranges(&text).map_err(serde::de::Error::custom)
}
PortRangesConfig::Pairs(pairs) => pairs,
};
if pairs.is_empty() {
return Err(serde::de::Error::custom("expected at least one port range"));
}
if let Some(&(start, end)) = pairs.iter().find(|&&(start, end)| start > end) {
return Err(serde::de::Error::custom(format!(
"invalid port range {start}-{end}: start must not be greater than end"
)));
}
Ok(PortRanges(pairs))
}
/// Represents the range of ports to be scanned.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct PortRange {
pub start: u16,
pub end: u16,
}
#[cfg(not(tarpaulin_include))]
/// Parse a single `start-end` token (e.g. "100-200") into `(start, end)`.
/// Returns `None` when the token is malformed, cannot be parsed as `u16`,
/// or `start > end`.
fn parse_range(input: &str) -> Option<(u16, u16)> {
let mut parts = input.trim().splitn(2, '-').map(str::trim);
let a = parts.next()?;
let b = parts.next()?;
fn parse_range(input: &str) -> Result<PortRange, String> {
let range = input
.split('-')
.map(str::parse)
.collect::<Result<Vec<u16>, std::num::ParseIntError>>();
let start = a.parse::<u16>().ok()?;
let end = b.parse::<u16>().ok()?;
if start <= end {
Some((start, end))
} else {
None
}
}
#[cfg(not(tarpaulin_include))]
/// Parse a comma-separated list of `start-end` ranges into `PortRanges`.
///
/// Errors with a helpful message identifying the bad token.
fn parse_ranges(input: &str) -> Result<PortRanges, String> {
let s = input.trim();
if s.is_empty() {
return Err("empty input: expected one or more comma-separated 'start-end' pairs".into());
if range.is_err() {
return Err(String::from(
"the range format must be 'start-end'. Example: 1-1000.",
));
}
let ranges_res: Result<Vec<(u16, u16)>, String> = s
.split(',')
.map(|token| {
let t = token.trim();
parse_range(t).ok_or_else(|| {
format!(
"invalid range token `{}` — expected `start-end` with 0 <= start <= end <= 65535",
t
)
})
})
.collect();
ranges_res.map(PortRanges)
match range.unwrap().as_slice() {
[start, end] => Ok(PortRange {
start: *start,
end: *end,
}),
_ => Err(String::from(
"the range format must be 'start-end'. Example: 1-1000.",
)),
}
}
#[derive(Parser, Debug, Clone)]
@@ -142,9 +81,9 @@ pub struct Opts {
#[arg(short, long, value_delimiter = ',')]
pub ports: Option<Vec<u16>>,
/// Ranges of ports as comma-separated start-end pairs. Example: 1-500,1000-2500,4000-7000
#[arg(short, long, conflicts_with = "ports", value_parser = parse_ranges)]
pub range: Option<PortRanges>,
/// A range of ports with format start-end. Example: 1-1000.
#[arg(short, long, conflicts_with = "ports", value_parser = parse_range)]
pub range: Option<PortRange>,
/// Whether to ignore the configuration file or not.
#[arg(short, long)]
@@ -175,7 +114,7 @@ pub struct Opts {
/// it will do every port at the same time. Although, your OS may not
/// support this.
#[arg(short, long, default_value = "4500")]
pub batch_size: usize,
pub batch_size: u16,
/// The timeout in milliseconds before a port is assumed to be closed.
#[arg(short, long, default_value = "1500")]
@@ -186,10 +125,9 @@ pub struct Opts {
#[arg(long, default_value = "1")]
pub tries: u8,
/// Automatically increases the Unix file-descriptor limit.
#[cfg_attr(not(unix), arg(hide = true))]
/// Automatically ups the ULIMIT with the value you provided.
#[arg(short, long)]
pub ulimit: Option<usize>,
pub ulimit: Option<u64>,
/// The order of scanning to be performed. The "serial" option will
/// scan ports in ascending order while the "random" option will scan
@@ -224,84 +162,25 @@ pub struct Opts {
/// UDP scanning mode, finds UDP ports that send back responses
#[arg(long)]
pub udp: bool,
/// Also list TCP ports that actively refused the connection (closed).
/// Closed ports are only printed; scripts are never run against them.
#[arg(long)]
pub closed: bool,
/// Milliseconds to wait after scanning a port (on every address) before
/// scanning the next one, for slow, low-noise scans. 0 disables the delay.
#[arg(long, default_value = "0", value_name = "MS")]
pub interval: u64,
/// Ids of the options given explicitly on the command line. The
/// configuration file never overrides these (see [`Opts::merge`]).
#[arg(skip)]
pub cli_args: HashSet<String>,
}
#[cfg(not(tarpaulin_include))]
impl Opts {
pub fn read() -> Self {
Self::try_read_from(std::env::args_os()).unwrap_or_else(|e| e.exit())
}
/// Parses `args` like [`Opts::read`] does, returning an error instead of
/// exiting.
fn try_read_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut command = Self::command();
let mut matches = command.try_get_matches_from_mut(args)?;
// clap fills in default values, so the parsed struct alone cannot
// tell `-b 4500` apart from no `-b` at all. Record what the user
// actually typed so that `merge` lets it win over the config file.
let cli_args = command
.get_arguments()
.map(|arg| arg.get_id().as_str())
.filter(|id| matches.value_source(id) == Some(ValueSource::CommandLine))
.map(str::to_owned)
.collect();
let mut opts =
Self::from_arg_matches_mut(&mut matches).map_err(|e| e.format(&mut command))?;
opts.cli_args = cli_args;
let mut opts = Opts::parse();
if opts.ports.is_none() && opts.range.is_none() {
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
opts.range = Some(PortRange {
start: LOWEST_PORT_NUMBER,
end: TOP_PORT_NUMBER,
});
}
Ok(opts)
}
/// Validates options whose availability or semantics depend on the
/// operating system.
///
/// # Errors
///
/// Returns an error when an option is unsupported on the current platform.
pub fn validate_platform(&self) -> Result<(), String> {
#[cfg(not(unix))]
{
if self.ulimit.is_some() {
return Err(
"--ulimit is only supported on Unix-like operating systems. \
On Windows, use --batch-size (-b) to control scan concurrency."
.to_owned(),
);
}
}
Ok(())
opts
}
/// Reads the command line arguments into an Opts struct and merge
/// values found within the user configuration file. Options given on the
/// command line take precedence over the configuration file.
/// values found within the user configuration file.
pub fn merge(&mut self, config: &Config) {
if !self.no_config {
self.merge_required(config);
@@ -314,9 +193,7 @@ impl Opts {
($($field: ident),+) => {
$(
if let Some(e) = &config.$field {
if !self.cli_args.contains(stringify!($field)) {
self.$field = e.clone();
}
self.$field = e.clone();
}
)+
}
@@ -324,7 +201,7 @@ impl Opts {
merge_required!(
addresses, greppable, accessible, batch_size, timeout, tries, scan_order, scripts,
command, udp, no_banner, closed, interval
command, udp
);
}
@@ -332,7 +209,7 @@ impl Opts {
macro_rules! merge_optional {
($($field: ident),+) => {
$(
if config.$field.is_some() && !self.cli_args.contains(stringify!($field)) {
if config.$field.is_some() {
self.$field = config.$field.clone();
}
)+
@@ -341,7 +218,11 @@ impl Opts {
// Only use top ports when the user asks for them
if self.top && config.ports.is_some() {
self.ports = config.ports.clone();
let mut ports: Vec<u16> = Vec::with_capacity(config.ports.as_ref().unwrap().len());
for entry in config.ports.as_ref().unwrap().keys() {
ports.push(entry.parse().unwrap());
}
self.ports = Some(ports);
}
merge_optional!(range, resolver, ulimit, exclude_ports, exclude_addresses);
@@ -371,9 +252,6 @@ impl Default for Opts {
exclude_ports: None,
exclude_addresses: None,
udp: false,
closed: false,
interval: 0,
cli_args: HashSet::new(),
}
}
}
@@ -385,14 +263,14 @@ impl Default for Opts {
#[derive(Debug, Deserialize)]
pub struct Config {
addresses: Option<Vec<String>>,
ports: Option<Vec<u16>>,
range: Option<PortRanges>,
ports: Option<HashMap<String, u16>>,
range: Option<PortRange>,
greppable: Option<bool>,
accessible: Option<bool>,
batch_size: Option<usize>,
batch_size: Option<u16>,
timeout: Option<u32>,
tries: Option<u8>,
ulimit: Option<usize>,
ulimit: Option<u64>,
resolver: Option<String>,
scan_order: Option<ScanOrder>,
command: Option<Vec<String>>,
@@ -400,9 +278,6 @@ pub struct Config {
exclude_ports: Option<Vec<u16>>,
exclude_addresses: Option<Vec<String>>,
udp: Option<bool>,
no_banner: Option<bool>,
closed: Option<bool>,
interval: Option<u64>,
}
#[cfg(not(tarpaulin_include))]
@@ -420,26 +295,10 @@ impl Config {
/// scan_order = "Serial"
/// exclude_ports = [8080, 9090, 80]
/// udp = false
/// closed = false
/// interval = 0
///
pub fn read(custom_config_path: Option<PathBuf>) -> Self {
let mut content = String::new();
let config_path = custom_config_path.unwrap_or_else(|| {
// Try the XDG-idiomatic location first, then fall back to legacy
// paths so existing users keep working unchanged.
for path in [
default_config_path(),
legacy_dot_config_path(),
old_default_config_path(),
] {
if path.exists() {
return path;
}
}
default_config_path()
});
let config_path = custom_config_path.unwrap_or_else(default_config_path);
if config_path.exists() {
content = match fs::read_to_string(config_path) {
Ok(content) => content,
@@ -450,9 +309,7 @@ impl Config {
let config: Config = match toml::from_str(&content) {
Ok(config) => config,
Err(e) => {
crate::tui::println_safe(format_args!(
"Found {e} in configuration file.\nAborting scan.\n"
));
println!("Found {e} in configuration file.\nAborting scan.\n");
std::process::exit(1);
}
};
@@ -461,30 +318,8 @@ impl Config {
}
}
/// Returns the preferred config file path: `$XDG_CONFIG_HOME/rustscan/config.toml`
/// on Linux (with the usual `~/.config` fallback when the variable is unset),
/// and the platform-equivalent `dirs::config_dir()` location on macOS / Windows.
/// Constructs default path to config toml
pub fn default_config_path() -> PathBuf {
let Some(mut config_path) = dirs::config_dir() else {
panic!("Could not infer config file path.");
};
config_path.push("rustscan");
config_path.push("config.toml");
config_path
}
/// Returns the transitional `$XDG_CONFIG_HOME/.rustscan.toml` path that older
/// builds wrote to. Kept readable for backwards compatibility.
pub fn legacy_dot_config_path() -> PathBuf {
let Some(mut config_path) = dirs::config_dir() else {
panic!("Could not infer config file path.");
};
config_path.push(".rustscan.toml");
config_path
}
/// Returns the deprecated home directory config path used for backwards compatibility.
pub fn old_default_config_path() -> PathBuf {
let Some(mut config_path) = dirs::home_dir() else {
panic!("Could not infer config file path.");
};
@@ -496,9 +331,8 @@ pub fn old_default_config_path() -> PathBuf {
mod tests {
use clap::{CommandFactory, Parser};
use parameterized::parameterized;
use std::collections::HashSet;
use super::{Config, Opts, PortRanges, ScanOrder, ScriptsRequired};
use super::{Config, Opts, PortRange, ScanOrder, ScriptsRequired};
impl Config {
fn default() -> Self {
@@ -519,9 +353,6 @@ mod tests {
exclude_ports: None,
exclude_addresses: None,
udp: Some(false),
no_banner: None,
closed: Some(false),
interval: None,
}
}
}
@@ -551,82 +382,6 @@ mod tests {
assert_eq!(command, opts.command);
}
#[test]
fn parses_explicit_batch_size() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "-b", "1234"]);
assert_eq!(opts.batch_size, 1234);
}
#[test]
fn parses_explicit_long_batch_size() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "4321"]);
assert_eq!(opts.batch_size, 4321);
}
#[test]
#[cfg(windows)]
fn windows_platform_validation_accepts_batch_size() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "500"]);
assert!(opts.validate_platform().is_ok());
assert_eq!(opts.batch_size, 500);
}
#[test]
#[cfg(windows)]
fn windows_rejects_ulimit() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]);
let error = opts
.validate_platform()
.expect_err("Windows must reject --ulimit");
assert!(error.contains("--ulimit"));
assert!(error.contains("Unix"));
assert!(error.contains("--batch-size"));
}
#[test]
#[cfg(windows)]
fn windows_hides_ulimit_from_help() {
let help = Opts::command().render_long_help().to_string();
assert!(
!help.contains("--ulimit"),
"--ulimit should not be advertised on Windows"
);
}
#[test]
#[cfg(windows)]
fn windows_rejects_ulimit_from_config_merge() {
let mut opts = Opts {
no_config: false,
..Default::default()
};
let mut config = Config::default();
config.ulimit = Some(5_000);
opts.merge(&config);
let error = opts
.validate_platform()
.expect_err("Windows must reject --ulimit supplied by configuration");
assert!(error.contains("--ulimit"));
}
#[test]
#[cfg(unix)]
fn unix_platform_validation_accepts_ulimit() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]);
assert!(opts.validate_platform().is_ok());
assert_eq!(opts.ulimit, Some(5_000));
}
#[test]
fn opts_no_merge_when_config_is_ignored() {
let mut opts = Opts::default();
@@ -662,7 +417,10 @@ mod tests {
fn opts_merge_optional_arguments() {
let mut opts = Opts::default();
let mut config = Config::default();
config.range = Some(PortRanges(vec![(1, 1_000)]));
config.range = Some(PortRange {
start: 1,
end: 1_000,
});
config.ulimit = Some(1_000);
config.resolver = Some("1.1.1.1".to_owned());
@@ -672,159 +430,4 @@ mod tests {
assert_eq!(opts.ulimit, config.ulimit);
assert_eq!(opts.resolver, config.resolver);
}
#[test]
fn cli_arguments_override_config() {
// Regression test for #722: the config file used to silently replace
// options given on the command line, e.g. `-r` with its `range`.
let mut opts = Opts::try_read_from([
"rustscan",
"-a",
"192.168.0.1",
"-r",
"8600-8650",
"-b",
"100",
// Passing clap's default value explicitly must still win.
"-t",
"1500",
"--tries",
"3",
"--scan-order",
"serial",
"--scripts",
"none",
"-g",
"--",
"-sV",
])
.unwrap();
let config: Config = toml::from_str(
r#"
addresses = ["127.0.0.1"]
range = { start = 1, end = 100 }
batch_size = 25000
timeout = 1000
tries = 1
scan_order = "Random"
scripts = "Custom"
greppable = false
command = ["-A"]
"#,
)
.unwrap();
opts.merge(&config);
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
assert_eq!(opts.range, Some(PortRanges(vec![(8_600, 8_650)])));
assert_eq!(opts.batch_size, 100);
assert_eq!(opts.timeout, 1_500);
assert_eq!(opts.tries, 3);
assert_eq!(opts.scan_order, ScanOrder::Serial);
assert_eq!(opts.scripts, ScriptsRequired::None);
assert!(opts.greppable);
assert_eq!(opts.command, vec!["-sV".to_owned()]);
}
#[test]
fn config_fills_arguments_not_given_on_cli() {
let mut opts = Opts::try_read_from(["rustscan", "-a", "192.168.0.1"]).unwrap();
let config: Config = toml::from_str(
r#"
addresses = ["127.0.0.1"]
range = { start = 1, end = 100 }
batch_size = 25000
timeout = 1000
tries = 3
scan_order = "Random"
greppable = true
command = ["-A"]
"#,
)
.unwrap();
opts.merge(&config);
// Only `-a` was typed; clap defaults such as `-b 4500` must not count.
assert_eq!(opts.cli_args, HashSet::from(["addresses".to_owned()]));
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
assert_eq!(opts.range, Some(PortRanges(vec![(1, 100)])));
assert_eq!(opts.batch_size, 25_000);
assert_eq!(opts.timeout, 1_000);
assert_eq!(opts.tries, 3);
assert_eq!(opts.scan_order, ScanOrder::Random);
assert!(opts.greppable);
assert_eq!(opts.command, vec!["-A".to_owned()]);
}
#[test]
fn parses_comma_separated_ranges() {
let opts = Opts::parse_from([
"rustscan",
"-a",
"127.0.0.1",
"-r",
"1-100, 200-300,5000-5100",
]);
assert_eq!(
opts.range,
Some(PortRanges(vec![(1, 100), (200, 300), (5_000, 5_100)]))
);
}
#[test]
fn rejects_malformed_ranges() {
for range in ["", "300-200", "1-100,", "1-2-3", "a-b", "1-70000"] {
assert!(
Opts::try_parse_from(["rustscan", "-a", "127.0.0.1", "-r", range]).is_err(),
"{:?} should be rejected",
range
);
}
}
#[test]
fn config_range_accepts_legacy_table() {
let config: Config = toml::from_str("range = { start = 1, end = 1000 }").unwrap();
assert_eq!(config.range, Some(PortRanges(vec![(1, 1_000)])));
}
#[test]
fn config_range_accepts_string_and_pairs() {
let expected = Some(PortRanges(vec![(1, 100), (200, 300)]));
let config: Config = toml::from_str("range = \"1-100,200-300\"").unwrap();
assert_eq!(config.range, expected);
let config: Config = toml::from_str("range = [[1, 100], [200, 300]]").unwrap();
assert_eq!(config.range, expected);
}
#[test]
fn config_range_rejects_invalid_ranges() {
for range in [
"range = { start = 10, end = 1 }",
"range = [[10, 1]]",
"range = []",
"range = \"10-1\"",
] {
assert!(
toml::from_str::<Config>(range).is_err(),
"{:?} should be rejected",
range
);
}
}
#[test]
fn parses_interval_in_milliseconds() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--interval", "250"]);
assert_eq!(opts.interval, 250);
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1"]);
assert_eq!(opts.interval, 0);
}
}
+6 -5
View File
@@ -7,19 +7,20 @@
//! [`Scanner`](crate::scanner::Scanner) which in turn requires a
//! [`PortStrategy`](crate::port_strategy::PortStrategy):
//!
//! This example is compiled but never executed by the test suite.
//!
//! ```no_run
//! ```rust
//! use async_std::task::block_on;
//! use std::{net::IpAddr, time::Duration};
//!
//! use rustscan::input::{PortRanges, ScanOrder};
//! use rustscan::input::{PortRange, ScanOrder};
//! use rustscan::port_strategy::PortStrategy;
//! use rustscan::scanner::Scanner;
//!
//! fn main() {
//! let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
//! let range = PortRanges(vec![(1, 1_000)]);
//! let range = PortRange {
//! start: 1,
//! end: 1_000,
//! };
//! let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random); // can be serial, random or manual https://github.com/RustScan/RustScan/blob/master/src/port_strategy/mod.rs
//! let scanner = Scanner::new(
//! &addrs, // the addresses to scan
+48 -137
View File
@@ -5,9 +5,8 @@
use rustscan::benchmark::{Benchmark, NamedTimer};
use rustscan::input::{self, Config, Opts, ScriptsRequired};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::{PortStatus, Scanner};
use rustscan::scanner::Scanner;
use rustscan::scripts::{init_scripts, Script, ScriptFile};
use rustscan::tui::println_safe;
use rustscan::{detail, funny_opening, output, warning};
use colorful::{Color, Colorful};
@@ -24,10 +23,9 @@ extern crate dirs;
// Average value for Ubuntu
#[cfg(unix)]
const DEFAULT_FILE_DESCRIPTORS_LIMIT: usize = 8000;
const DEFAULT_FILE_DESCRIPTORS_LIMIT: u64 = 8000;
// Safest batch size based on experimentation
#[cfg(unix)]
const AVERAGE_BATCH_SIZE: usize = 3000;
const AVERAGE_BATCH_SIZE: u16 = 3000;
#[macro_use]
extern crate log;
@@ -48,12 +46,7 @@ fn main() {
let config = Config::read(opts.config_path.clone());
opts.merge(&config);
if let Err(message) = opts.validate_platform() {
eprintln!("error: {message}");
std::process::exit(2);
}
debug!("Main() `opts` arguments are {opts:?}");
debug!("Main() `opts` arguments are {:?}", opts);
let scripts_to_run: Vec<ScriptFile> = match init_scripts(&opts.scripts) {
Ok(scripts_to_run) => scripts_to_run,
@@ -67,7 +60,7 @@ fn main() {
}
};
debug!("Scripts initialized {scripts_to_run:?}");
debug!("Scripts initialized {:?}", &scripts_to_run);
if !opts.greppable && !opts.accessible && !opts.no_banner {
print_opening(&opts);
@@ -84,10 +77,13 @@ fn main() {
std::process::exit(1);
}
let batch_size = effective_batch_size(&opts);
debug!("Effective batch size: {batch_size}");
#[cfg(unix)]
let batch_size: u16 = infer_batch_size(&opts, adjust_ulimit_size(&opts));
let mut scanner = Scanner::new(
#[cfg(not(unix))]
let batch_size: u16 = AVERAGE_BATCH_SIZE;
let scanner = Scanner::new(
&ips,
batch_size,
Duration::from_millis(opts.timeout.into()),
@@ -97,30 +93,21 @@ fn main() {
opts.accessible,
opts.exclude_ports.unwrap_or_default(),
opts.udp,
)
.with_open_port_output();
if opts.closed {
scanner = scanner.with_closed_ports();
}
if opts.interval > 0 {
scanner = scanner.with_interval(Duration::from_millis(opts.interval));
}
debug!("Scanner finished building: {scanner:?}");
);
debug!("Scanner finished building: {:?}", scanner);
let mut portscan_bench = NamedTimer::start("Portscan");
let scan_result = block_on(scanner.run_with_status());
let scan_result = block_on(scanner.run());
portscan_bench.end();
benchmarks.push(portscan_bench);
let mut ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
let mut closed_ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
let mut ports_per_ip = HashMap::new();
for status in scan_result {
let (map, socket) = match status {
PortStatus::Open(socket) => (&mut ports_per_ip, socket),
PortStatus::Closed(socket) => (&mut closed_ports_per_ip, socket),
};
map.entry(socket.ip()).or_default().push(socket.port());
for socket in scan_result {
ports_per_ip
.entry(socket.ip())
.or_insert_with(Vec::new)
.push(socket.port());
}
for ip in ips {
@@ -135,7 +122,7 @@ fn main() {
\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,
opts.batch_size,
"'rustscan -b <batch_size> -a <ip address>'");
warning!(x, opts.greppable, opts.accessible);
}
@@ -149,19 +136,17 @@ fn main() {
// if option scripts is none, no script will be spawned
if opts.greppable || opts.scripts == ScriptsRequired::None {
println_safe(format_args!("{ip} -> [{ports_str}]"));
println!("{} -> [{}]", &ip, ports_str);
continue;
}
detail!("Starting Script(s)", opts.greppable, opts.accessible);
// Run all the scripts we found and parsed based on the script config file tags field.
for script_f in &scripts_to_run {
// Clone only the script being run instead of the whole list per IP.
let mut script_f = script_f.clone();
for mut script_f in scripts_to_run.clone() {
// This part allows us to add commandline arguments to the Script call_format, appending them to the end of the command.
if !opts.command.is_empty() {
let user_extra_args = &opts.command.join(" ");
debug!("Extra args vec {user_extra_args:?}");
debug!("Extra args vec {:?}", user_extra_args);
if script_f.call_format.is_some() {
let mut call_f = script_f.call_format.unwrap();
call_f.push(' ');
@@ -171,7 +156,7 @@ fn main() {
opts.greppable,
opts.accessible
);
debug!("Call format {call_f}");
debug!("Call format {}", call_f);
script_f.call_format = Some(call_f);
}
}
@@ -188,7 +173,7 @@ fn main() {
);
match script.run() {
Ok(script_result) => {
detail!(script_result.clone(), opts.greppable, opts.accessible);
detail!(script_result.to_string(), opts.greppable, opts.accessible);
}
Err(e) => {
warning!(&format!("Error {e}"), opts.greppable, opts.accessible);
@@ -197,45 +182,15 @@ fn main() {
}
}
// Closed ports are only listed; scripts are never run against them.
if opts.closed {
println_safe(format_args!("closed ports:"));
for (ip, ports) in &closed_ports_per_ip {
let ports_str = ports
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
println_safe(format_args!("{ip} -> [{ports_str}]"));
}
}
// To use the runtime benchmark, run the process as: RUST_LOG=info ./rustscan
script_bench.end();
benchmarks.push(script_bench);
rustscan_bench.end();
benchmarks.push(rustscan_bench);
debug!("Benchmarks raw {benchmarks:?}");
debug!("Benchmarks raw {:?}", benchmarks);
info!("{}", benchmarks.summary());
}
/// Determines the actual batch size used by the scanner.
///
/// Unix systems may reduce the requested batch size according to the process
/// file-descriptor limit. Other platforms, including Windows, use the batch
/// size explicitly requested by the user.
fn effective_batch_size(opts: &Opts) -> usize {
#[cfg(unix)]
{
infer_batch_size(opts, adjust_ulimit_size(opts))
}
#[cfg(not(unix))]
{
opts.batch_size
}
}
/// Prints the opening title of RustScan
#[allow(clippy::items_after_statements, clippy::needless_raw_string_hashes)]
fn print_opening(opts: &Opts) {
@@ -246,12 +201,12 @@ fn print_opening(opts: &Opts) {
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner."#;
println_safe(format_args!("{}", s.gradient(Color::Green).bold()));
println!("{}", s.gradient(Color::Green).bold());
let info = r#"________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------"#;
println_safe(format_args!("{}", info.gradient(Color::Yellow).bold()));
println!("{}", info.gradient(Color::Yellow).bold());
funny_opening!();
let config_path = opts
@@ -264,26 +219,13 @@ The Modern Day Port Scanner."#;
opts.greppable,
opts.accessible
);
if opts.config_path.is_none() {
let old_config_path = input::old_default_config_path();
detail!(
format!(
"For backwards compatibility, the config file may also be at {old_config_path:?}"
),
opts.greppable,
opts.accessible
);
}
}
#[cfg(unix)]
fn adjust_ulimit_size(opts: &Opts) -> usize {
fn adjust_ulimit_size(opts: &Opts) -> u64 {
use rlimit::Resource;
use std::convert::TryInto;
if let Some(limit) = opts.ulimit {
let limit = limit as u64;
if Resource::NOFILE.set(limit, limit).is_ok() {
detail!(
format!("Automatically increasing ulimit value to {limit}."),
@@ -300,15 +242,14 @@ fn adjust_ulimit_size(opts: &Opts) -> usize {
}
let (soft, _) = Resource::NOFILE.get().unwrap();
// On 32-bit targets the soft limit (e.g. RLIM_INFINITY) may not fit in a
// usize. Fall back to the conservative default rather than usize::MAX,
// which would skip every batch-size adjustment in infer_batch_size.
soft.try_into().unwrap_or(DEFAULT_FILE_DESCRIPTORS_LIMIT)
soft
}
#[cfg(unix)]
fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
let mut batch_size = opts.batch_size;
fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
use std::convert::TryInto;
let mut batch_size: u64 = opts.batch_size.into();
// Adjust the batch size when the ulimit value is lower than the desired batch size
if ulimit < batch_size {
@@ -319,8 +260,8 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
// When the OS supports high file limits like 8000, but the user
// selected a batch size higher than this we should reduce it to
// a lower number.
if ulimit < AVERAGE_BATCH_SIZE {
// ulimit is smaller than average batch size
if ulimit < AVERAGE_BATCH_SIZE.into() {
// ulimit is smaller than aveage batch size
// user must have very small ulimit
// decrease batch size to half of ulimit
warning!("Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'. ", opts.greppable, opts.accessible);
@@ -328,7 +269,7 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
batch_size = ulimit / 2;
} else if ulimit > DEFAULT_FILE_DESCRIPTORS_LIMIT {
info!("Batch size is now average batch size");
batch_size = AVERAGE_BATCH_SIZE;
batch_size = AVERAGE_BATCH_SIZE.into();
} else {
batch_size = ulimit - 100;
}
@@ -341,12 +282,12 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
}
batch_size
.try_into()
.expect("Couldn't fit the batch size into a u16.")
}
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::effective_batch_size;
#[cfg(unix)]
use super::{adjust_ulimit_size, infer_batch_size};
use super::{print_opening, Opts};
@@ -372,7 +313,7 @@ mod tests {
};
let batch_size = infer_batch_size(&opts, 9_000);
assert_eq!(batch_size, 3_000);
assert!(batch_size == 3_000);
}
#[test]
#[cfg(unix)]
@@ -385,7 +326,7 @@ mod tests {
};
let batch_size = infer_batch_size(&opts, 5_000);
assert_eq!(batch_size, 4_900);
assert!(batch_size == 4_900);
}
#[test]
#[cfg(unix)]
@@ -398,7 +339,7 @@ mod tests {
};
let batch_size = adjust_ulimit_size(&opts);
assert_eq!(batch_size, 2_000);
assert!(batch_size == 2_000);
}
#[test]
@@ -412,46 +353,16 @@ mod tests {
let batch_size = infer_batch_size(&opts, 1_000_000);
assert_eq!(batch_size, opts.batch_size);
assert!(batch_size == opts.batch_size);
}
#[test]
fn test_print_opening_no_panic() {
let opts = Opts::default();
let opts = Opts {
ulimit: Some(2_000),
..Default::default()
};
// print opening should not panic
print_opening(&opts);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_uses_requested_value() {
let opts = Opts {
batch_size: 50,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 50);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_preserves_large_requested_value() {
let opts = Opts {
batch_size: 12_345,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 12_345);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_preserves_single_connection() {
let opts = Opts {
batch_size: 1,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 1);
}
}
+27 -37
View File
@@ -1,6 +1,6 @@
//! Provides a means to hold configuration options specifically for port scanning.
mod range_iterator;
use crate::input::{PortRanges, ScanOrder};
use crate::input::{PortRange, ScanOrder};
use rand::rng;
use rand::seq::SliceRandom;
use range_iterator::RangeIterator;
@@ -17,18 +17,20 @@ pub enum PortStrategy {
}
impl PortStrategy {
pub fn pick(range: &Option<PortRanges>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
pub fn pick(range: &Option<PortRange>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
match order {
ScanOrder::Serial if ports.is_none() => {
let port_ranges = range.as_ref().unwrap();
let range = range.as_ref().unwrap();
PortStrategy::Serial(SerialRange {
range: port_ranges.0.clone(),
start: range.start,
end: range.end,
})
}
ScanOrder::Random if ports.is_none() => {
let port_ranges = range.as_ref().unwrap();
let range = range.as_ref().unwrap();
PortStrategy::Random(RandomRange {
range: port_ranges.0.clone(),
start: range.start,
end: range.end,
})
}
ScanOrder::Serial => PortStrategy::Manual(ports.unwrap()),
@@ -60,12 +62,13 @@ trait RangeOrder {
/// ascending order.
#[derive(Debug)]
pub struct SerialRange {
range: Vec<(u16, u16)>,
start: u16,
end: u16,
}
impl RangeOrder for SerialRange {
fn generate(&self) -> Vec<u16> {
RangeIterator::new_serial(&self.range).collect()
(self.start..=self.end).collect()
}
}
@@ -73,7 +76,8 @@ impl RangeOrder for SerialRange {
/// a random order. This vector is built following the LCG algorithm.
#[derive(Debug)]
pub struct RandomRange {
range: Vec<(u16, u16)>,
start: u16,
end: u16,
}
impl RangeOrder for RandomRange {
@@ -87,47 +91,33 @@ impl RangeOrder for RandomRange {
// port numbers close to each other are pretty slim due to the way the
// algorithm works.
fn generate(&self) -> Vec<u16> {
RangeIterator::new_random(&self.range).collect()
RangeIterator::new(self.start.into(), self.end.into()).collect()
}
}
#[cfg(test)]
mod tests {
use super::PortStrategy;
use crate::input::{PortRanges, ScanOrder};
use std::collections::HashSet;
use crate::input::{PortRange, ScanOrder};
fn expected_ports_from_ranges(input: &[(u16, u16)]) -> Vec<u16> {
let mut s = HashSet::new();
for &(start, end) in input {
for p in start..=end {
s.insert(p);
}
}
let mut v: Vec<u16> = s.into_iter().collect();
v.sort_unstable();
v
}
#[test]
fn serial_strategy_with_range() {
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Serial);
let range = PortRange { start: 1, end: 100 };
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Serial);
let result = strategy.order();
let expected = expected_ports_from_ranges(&ranges.0);
assert_eq!(expected, result);
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
}
#[test]
fn random_strategy_with_range() {
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Random);
let range = PortRange { start: 1, end: 100 };
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let mut result = strategy.order();
let expected = expected_ports_from_ranges(&ranges.0);
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_ne!(expected_range, result);
assert_ne!(expected, result);
result.sort_unstable();
assert_eq!(expected, result);
assert_eq!(expected_range, result);
}
#[test]
@@ -141,10 +131,10 @@ mod tests {
fn random_strategy_with_ports() {
let strategy = PortStrategy::pick(&None, Some((1..10).collect()), ScanOrder::Random);
let mut result = strategy.order();
let expected = (1..10).collect::<Vec<u16>>();
assert_ne!(expected, result);
let expected_range = (1..10).collect::<Vec<u16>>();
assert_ne!(expected_range, result);
result.sort_unstable();
assert_eq!(expected, result);
assert_eq!(expected_range, result);
}
}
+57 -251
View File
@@ -1,196 +1,71 @@
use gcd::Gcd;
use rand::RngExt;
use rand::Rng;
use std::convert::TryInto;
/// Yields ports produced from a collection of (possibly overlapping)
/// inclusive `u16` ranges in two modes:
///
/// **Randomized** — created by `RangeIterator::new_random`:
/// - The algorithm generates a permutation of indices `0..N-1` using the
/// additive congruential step `x_{i+1} = (x_i + step) % N`.
/// - `step` is chosen so `gcd(step, N) == 1` to ensure the sequence is a
/// full-length cycle (visits each index exactly once).
/// - `x_0` (the seed stored as `normalized_first_pick`) is chosen uniformly
/// in `0..N`.
///
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
///
/// **Serial** — `RangeIterator::new_serial`:
/// Iterates the input ranges in the **original input order** and yields
/// each port the first time it is encountered. Duplicate ports (from
/// overlapping ranges) are skipped using a 65_536-entry "seen" table.
pub struct RangeIterator {
active: bool,
total: u32,
normalized_end: u32,
normalized_first_pick: u32,
normalized_pick: u32,
actual_start: u32,
step: u32,
ranges: Vec<(u32, u32)>,
prefix: Vec<u32>,
serial_itr: Option<Box<dyn Iterator<Item = u16>>>,
/// `seen[port]` is true once `port` has been yielded (serial mode only).
serial_seen: Option<Vec<bool>>,
}
/// An iterator that follows the `Linear Congruential Generator` algorithm.
///
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
impl RangeIterator {
/// Construct a randomized iterator (LCG permutation).
/// Receives the start and end of a range and normalize
/// these values before selecting a coprime for the end of the range
/// which will server as the step for the algorithm.
///
/// Preconditions:
/// - `input` must contain at least one `(u16,u16)`
/// and each pair must satisfy `start <= end`.
pub fn new_random(input: &[(u16, u16)]) -> Self {
// normalize & merge into (start, len) u32 pairs
// Example: [(10,12),(11,15)] -> merged [(10,6)]
let mut ranges: Vec<(u32, u32)> = input
.iter()
.map(|(s, e)| {
let start = *s as u32;
let end_excl = (*e as u32) + 1; // convert inclusive -> exclusive
(start, end_excl)
})
.collect();
/// For example, the range `1000-2500` will be normalized to `0-1500`
/// before going through the algorithm.
pub fn new(start: u32, end: u32) -> Self {
let normalized_end = end - start + 1;
let step = pick_random_coprime(normalized_end);
ranges.sort_unstable_by_key(|&(s, _)| s);
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
if !ranges.is_empty() {
let mut iter = ranges.into_iter();
let (mut cur_s, mut cur_end) = iter.next().unwrap(); // cur_end exclusive
for (s, end_excl) in iter {
if s <= cur_end {
// overlap/adjacent -> extend
if end_excl > cur_end {
cur_end = end_excl;
}
} else {
// push disjoint segment as (start, len)
merged.push((cur_s, cur_end - cur_s)); // len = excl - start
cur_s = s;
cur_end = end_excl;
}
}
merged.push((cur_s, cur_end - cur_s));
}
// build prefix sums (prefix[0] = 0; prefix.len() == merged.len() + 1)
let prefix = merged.iter().fold(vec![0u32], |mut acc, (_, len)| {
let last = acc.last().unwrap();
acc.push(last.saturating_add(*len));
acc
});
// total is guaranteed > 0 by precondition (input.len() >= 1)
let total = *prefix.last().unwrap();
// pick step and seed
let step = pick_random_coprime(total);
// Randomly choose a number within the range to be the first
// and assign it as a pick.
let mut rng = rand::rng();
let first = rng.random_range(0..total);
let normalized_first_pick = rng.random_range(0..normalized_end);
Self {
active: true,
total,
normalized_first_pick: first,
normalized_pick: first,
normalized_end,
step,
ranges: merged,
prefix,
serial_itr: None,
serial_seen: None,
}
}
/// Construct a serial iterator that yields ports in original input order,
/// skipping duplicates. The deduplication is done on the fly with a seen-table.
///
/// Preconditions:
/// - `input` must contain at least one `(u16,u16)` and each pair must satisfy `start <= end`.
pub fn new_serial(input: &[(u16, u16)]) -> Self {
// Build a serial iterator that yields ports in *input order* (start..=end).
// We keep the merged ranges/prefix empty here (they are not needed for serial mode).
let input = input.to_vec();
let serial_itr = input.into_iter().flat_map(|(start, end)| start..=end);
let serial_itr_boxed: Box<dyn Iterator<Item = u16>> = Box::new(serial_itr);
// One entry per possible port (0..=65535).
let seen = vec![false; usize::from(u16::MAX) + 1];
Self {
active: true,
total: 0,
normalized_first_pick: 0,
normalized_pick: 0,
step: 0,
ranges: Vec::new(),
prefix: Vec::new(),
serial_itr: Some(serial_itr_boxed),
serial_seen: Some(seen),
normalized_first_pick,
normalized_pick: normalized_first_pick,
actual_start: start,
}
}
}
impl Iterator for RangeIterator {
type Item = u16;
/// Advance the iterator by one port.
///
/// 1. Read the current normalized index `cur`.
/// 2. Compute `next = (cur + step) % total` and update `normalized_pick`.
/// 3. If `next == normalized_first_pick` mark `active = false` (we completed the cycle).
/// 4. Map the returned index `cur` into the merged ranges via the prefix array:
/// - find the range index `idx` where `prefix[idx] <= cur < prefix[idx+1]`,
/// - offset = `cur - prefix[idx]`,
/// - port = `ranges[idx].0 + offset`.
/// 5. Return `port as u16`.
///
// The next step is always bound by the formula: N+1 = (N + STEP) % TOP_OF_THE_RANGE
// It will only stop once we generate a number equal to the first generated number.
fn next(&mut self) -> Option<Self::Item> {
if !self.active {
return None;
}
// SERIAL iterator fast-path: preserve original input order but skip duplicates.
if let (Some(it), Some(seen)) = (self.serial_itr.as_mut(), self.serial_seen.as_mut()) {
for p in it.by_ref() {
// Yield each port only the first time it is seen.
if !std::mem::replace(&mut seen[usize::from(p)], true) {
return Some(p);
}
// otherwise skip duplicate and continue
}
// serial iterator exhausted: drop it and mark inactive
self.serial_itr = None;
self.serial_seen = None;
self.active = false;
return None;
}
let current_pick = self.normalized_pick;
let next_pick = (current_pick + self.step) % self.normalized_end;
// RANDOMIZED (LCG) path
let cur = self.normalized_pick;
let next = (cur + self.step) % self.total;
// if next equals the original seed we finished the cycle after returning cur
if next == self.normalized_first_pick {
// If the next pick is equal to the first pick this means that
// we have iterated through the entire range.
if next_pick == self.normalized_first_pick {
self.active = false;
}
self.normalized_pick = next;
// Map cur -> port using prefix + ranges (binary search)
let mut lo: usize = 0;
let mut hi: usize = self.ranges.len();
while lo < hi {
let mid = (lo + hi) / 2;
if self.prefix[mid + 1] > cur {
hi = mid;
} else {
lo = mid + 1;
}
}
let idx = lo;
let offset = cur - self.prefix[idx];
let (start, _len) = self.ranges[idx];
let port = (start + offset)
.try_into()
.expect("Could not convert u32 to u16");
Some(port)
self.normalized_pick = next_pick;
Some(
(self.actual_start + current_pick)
.try_into()
.expect("Could not convert u32 to u16"),
)
}
}
@@ -223,105 +98,36 @@ fn pick_random_coprime(end: u32) -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
// Helper: collect, sort and return ports produced by randomized RangeIterator
fn generate_sorted_from_ranges_random(input: &[(u16, u16)]) -> Vec<u16> {
let mut it = RangeIterator::new_random(input);
let mut v: Vec<u16> = it.by_ref().collect();
v.sort_unstable();
v
}
// Helper: collect, sort and return ports produced by serial RangeIterator
fn generate_sorted_from_ranges_serial(input: &[(u16, u16)]) -> Vec<u16> {
let mut it = RangeIterator::new_serial(input);
let mut v: Vec<u16> = it.by_ref().collect();
v.sort_unstable();
v
}
// Build expected sorted unique ports from input ranges (inclusive)
fn expected_ports_from_ranges(input: &[(u16, u16)]) -> Vec<u16> {
let mut s = HashSet::new();
for &(start, end) in input {
for p in start..=end {
s.insert(p);
}
}
let mut v: Vec<u16> = s.into_iter().collect();
v.sort_unstable();
v
}
use super::RangeIterator;
#[test]
fn random_range_iterator_test() {
// small disjoint ranges
let input = &[(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
fn range_iterator_iterates_through_the_entire_range() {
let result = generate_sorted_range(1, 10);
let expected_range = (1..=10).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// larger disjoint ranges
let input = &[(1u16, 100u16), (200u16, 500u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
let result = generate_sorted_range(1, 100);
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// overlapping and adjacent
let input = &[(10u16, 20u16), (15u16, 25u16), (26u16, 30u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
let result = generate_sorted_range(1, 1000);
let expected_range = (1..=1000).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// near-full domain (heavy): we only assert lengths & equality
let input = &[(1u16, 65_535u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected.len(), result.len());
assert_eq!(expected, result);
let result = generate_sorted_range(1, 65_535);
let expected_range = (1..=65_535).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// multiple disjoint ranges - check dedupe & coverage
let input = &[(50u16, 100u16), (1000u16, 2000u16), (30000u16, 30010u16)];
let result = generate_sorted_from_ranges_random(input);
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
assert_eq!(set_len, result.len());
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
let result = generate_sorted_range(1000, 2000);
let expected_range = (1000..=2000).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
}
#[test]
fn serial_range_iterator_test() {
// serial should preserve input-order semantics but here we only assert
// coverage (no duplicates) by sorting results and comparing expected set.
fn generate_sorted_range(start: u32, end: u32) -> Vec<u16> {
let range = RangeIterator::new(start, end);
let mut result = range.into_iter().collect::<Vec<u16>>();
result.sort_unstable();
// small disjoint ranges
let input = &[(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)];
let result = generate_sorted_from_ranges_serial(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
// overlapping and adjacent
let input = &[(10u16, 20u16), (15u16, 25u16), (26u16, 30u16)];
let result = generate_sorted_from_ranges_serial(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
// multiple disjoint ranges
let input = &[(50u16, 100u16), (1000u16, 2000u16), (30000u16, 30010u16)];
let result = generate_sorted_from_ranges_serial(input);
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
assert_eq!(set_len, result.len());
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
// all possible inputs
let input = &[(u16::MIN, u16::MAX)];
let result = generate_sorted_from_ranges_serial(input);
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
assert_eq!(set_len, result.len());
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
result
}
}
+248 -262
View File
@@ -1,7 +1,6 @@
//! Core functionality for actual scanning behaviour.
use crate::generated::get_parsed_data;
use crate::port_strategy::PortStrategy;
use crate::tui::println_safe;
use log::debug;
mod socket_iterator;
@@ -9,41 +8,17 @@ use socket_iterator::SocketIterator;
use async_std::net::TcpStream;
use async_std::prelude::*;
use async_std::task::sleep;
use async_std::{io, net::UdpSocket};
use colored::Colorize;
use futures::stream::FuturesUnordered;
use std::collections::BTreeMap;
use std::{
collections::{HashMap, HashSet},
collections::HashSet,
net::{IpAddr, Shutdown, SocketAddr},
num::NonZeroU8,
sync::Arc,
time::Duration,
};
/// UDP payload lookup: port -> payload bytes
///
/// `get_parsed_data()` returns a `&'static BTreeMap<...>`, so we can store
/// references to the payload bytes without cloning them.
#[doc(hidden)]
pub type UdpPayloadLookup = HashMap<u16, &'static [u8]>;
#[doc(hidden)]
pub fn build_udp_payload_lookup(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>) -> UdpPayloadLookup {
let mut lookup: UdpPayloadLookup = HashMap::new();
for (ports, payload_vec) in udp_map.iter() {
let payload: &'static [u8] = payload_vec.as_slice();
for &port in ports.iter() {
// Preserve existing behavior: if duplicates exist, last insert wins.
lookup.insert(port, payload);
}
}
lookup
}
/// The class for the scanner
/// IP is data type IpAddr and is the IP address
/// start & end is where the port scan starts and ends
@@ -54,7 +29,7 @@ pub fn build_udp_payload_lookup(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>) -
#[derive(Debug)]
pub struct Scanner {
ips: Vec<IpAddr>,
batch_size: usize,
batch_size: u16,
timeout: Duration,
tries: NonZeroU8,
greppable: bool,
@@ -62,19 +37,6 @@ pub struct Scanner {
accessible: bool,
exclude_ports: Vec<u16>,
udp: bool,
print_open_ports: bool,
report_closed: bool,
interval: Duration,
}
/// The outcome for a single socket, as returned by [`Scanner::run_with_status`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortStatus {
/// The TCP connection succeeded, or the UDP target answered.
Open(SocketAddr),
/// The target actively refused the TCP connection (for example with a
/// RST). Only reported when [`Scanner::with_closed_ports`] is enabled.
Closed(SocketAddr),
}
// Allowing too many arguments for clippy.
@@ -82,7 +44,7 @@ pub enum PortStatus {
impl Scanner {
pub fn new(
ips: &[IpAddr],
batch_size: usize,
batch_size: u16,
timeout: Duration,
tries: u8,
greppable: bool,
@@ -101,62 +63,13 @@ impl Scanner {
accessible,
exclude_ports,
udp,
print_open_ports: false,
report_closed: false,
interval: Duration::ZERO,
}
}
/// Enables the CLI's incremental open-port output.
///
/// Library callers are quiet by default and can inspect the sockets returned by [`Self::run`].
#[must_use]
pub fn with_open_port_output(mut self) -> Self {
self.print_open_ports = true;
self
}
/// Also reports TCP ports that actively refuse the connection, as
/// [`PortStatus::Closed`] from [`Self::run_with_status`].
///
/// Ports that time out are still treated as filtered and are not reported.
/// UDP scans never report closed ports.
#[must_use]
pub fn with_closed_ports(mut self) -> Self {
self.report_closed = true;
self
}
/// Waits `interval` after scanning one port (on every address) before
/// scanning the next port, for slow, low-noise scans.
///
/// Within a port, up to `batch_size` addresses are still scanned
/// concurrently. A zero interval (the default) scans all sockets in
/// batches without any delay.
#[must_use]
pub fn with_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
/// Runs scan_range with chunk sizes
/// If you want to run RustScan normally, this is the entry point used
/// Returns all open sockets.
/// Returns all open ports as `Vec<u16>`
pub async fn run(&self) -> Vec<SocketAddr> {
self.run_with_status()
.await
.into_iter()
.filter_map(|status| match status {
PortStatus::Open(socket) => Some(socket),
PortStatus::Closed(_) => None,
})
.collect()
}
/// Like [`Self::run`], but returns the status of every socket that gave a
/// definitive answer: open sockets, plus closed sockets when
/// [`Self::with_closed_ports`] is enabled.
pub async fn run_with_status(&self) -> Vec<PortStatus> {
let ports: Vec<u16> = self
.port_strategy
.order()
@@ -164,73 +77,33 @@ impl Scanner {
.filter(|&port| !self.exclude_ports.contains(port))
.copied()
.collect();
let mut found_sockets: Vec<PortStatus> = Vec::new();
let mut errors: HashSet<String> = HashSet::new();
// Build UDP payload lookup once (only if we are scanning UDP).
// This avoids cloning a big map into every spawned future and turns
// payload selection from O(n) to O(1).
let udp_payloads: Option<Arc<UdpPayloadLookup>> = if self.udp {
Some(Arc::new(build_udp_payload_lookup(get_parsed_data())))
} else {
None
};
debug!("Start scanning sockets. \nBatch size {}\nNumber of ip-s {}\nNumber of ports {}\nTargets all together {}\nInterval between ports {:?}",
self.batch_size,
self.ips.len(),
ports.len(),
(self.ips.len() * ports.len()),
self.interval);
if self.interval.is_zero() {
let sockets = SocketIterator::new(&self.ips, &ports);
self.scan_sockets(sockets, &udp_payloads, &mut found_sockets, &mut errors)
.await;
} else {
// Scan one port (on every address) at a time and wait `interval`
// before moving on to the next port.
for (i, port) in ports.iter().enumerate() {
if i > 0 {
sleep(self.interval).await;
}
let sockets = SocketIterator::new(&self.ips, std::slice::from_ref(port));
self.scan_sockets(sockets, &udp_payloads, &mut found_sockets, &mut errors)
.await;
}
}
debug!("Typical socket connection errors {errors:?}");
debug!("Sockets found: {:?}", found_sockets);
found_sockets
}
/// Scans every socket yielded by `sockets`, keeping at most `batch_size`
/// connection attempts in flight.
async fn scan_sockets(
&self,
mut sockets: SocketIterator<'_>,
udp_payloads: &Option<Arc<UdpPayloadLookup>>,
found_sockets: &mut Vec<PortStatus>,
errors: &mut HashSet<String>,
) {
let mut socket_iterator: SocketIterator = SocketIterator::new(&self.ips, &ports);
let mut open_sockets: Vec<SocketAddr> = Vec::new();
let mut ftrs = FuturesUnordered::new();
let mut errors: HashSet<String> = HashSet::new();
let udp_map = get_parsed_data();
for _ in 0..self.batch_size {
if let Some(socket) = sockets.next() {
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
if let Some(socket) = socket_iterator.next() {
ftrs.push(self.scan_socket(socket, udp_map.clone()));
} else {
break;
}
}
debug!("Start scanning sockets. \nBatch size {}\nNumber of ip-s {}\nNumber of ports {}\nTargets all together {} ",
self.batch_size,
self.ips.len(),
&ports.len(),
(self.ips.len() * ports.len()));
while let Some(result) = ftrs.next().await {
if let Some(socket) = sockets.next() {
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
if let Some(socket) = socket_iterator.next() {
ftrs.push(self.scan_socket(socket, udp_map.clone()));
}
match result {
Ok(status) => found_sockets.push(status),
Ok(socket) => open_sockets.push(socket),
Err(e) => {
let error_string = e.to_string();
if errors.len() < self.ips.len() * 1000 {
@@ -239,6 +112,9 @@ impl Scanner {
}
}
}
debug!("Typical socket connection errors {:?}", errors);
debug!("Open Sockets found: {:?}", &open_sockets);
open_sockets
}
/// Given a socket, scan it self.tries times.
@@ -258,33 +134,29 @@ impl Scanner {
async fn scan_socket(
&self,
socket: SocketAddr,
udp_payloads: Option<Arc<UdpPayloadLookup>>,
) -> io::Result<PortStatus> {
udp_map: BTreeMap<Vec<u16>, Vec<u8>>,
) -> io::Result<SocketAddr> {
if self.udp {
return self.scan_udp_socket(socket, udp_payloads).await;
return self.scan_udp_socket(socket, udp_map).await;
}
let tries = self.tries.get();
for nr_try in 1..=tries {
match self.connect(socket).await {
Ok(tcp_stream) => {
debug!("Connection was successful, shutting down stream {}", socket);
debug!(
"Connection was successful, shutting down stream {}",
&socket
);
if let Err(e) = tcp_stream.shutdown(Shutdown::Both) {
debug!("Shutdown stream error {}", e);
debug!("Shutdown stream error {}", &e);
}
self.fmt_ports(socket);
debug!("Return Ok after {nr_try} tries");
return Ok(PortStatus::Open(socket));
debug!("Return Ok after {} tries", nr_try);
return Ok(socket);
}
Err(e) => {
// A refused connection is a definitive answer, so there is
// no point in retrying it.
if self.report_closed && e.kind() == io::ErrorKind::ConnectionRefused {
self.fmt_closed_port(socket);
return Ok(PortStatus::Closed(socket));
}
let mut error_string = e.to_string();
assert!(!error_string.to_lowercase().contains("too many open files"), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500.");
@@ -292,7 +164,7 @@ impl Scanner {
if nr_try == tries {
error_string.push(' ');
error_string.push_str(&socket.ip().to_string());
return Err(io::Error::other(error_string));
return Err(io::Error::new(io::ErrorKind::Other, error_string));
}
}
};
@@ -303,25 +175,28 @@ impl Scanner {
async fn scan_udp_socket(
&self,
socket: SocketAddr,
udp_payloads: Option<Arc<UdpPayloadLookup>>,
) -> io::Result<PortStatus> {
let payload: &[u8] = udp_payloads
.as_ref()
.and_then(|m| m.get(&socket.port()).copied())
.unwrap_or(b"");
udp_map: BTreeMap<Vec<u16>, Vec<u8>>,
) -> io::Result<SocketAddr> {
let mut payload: Vec<u8> = Vec::new();
for (key, value) in udp_map {
if key.contains(&socket.port()) {
payload = value;
}
}
let tries = self.tries.get();
for _ in 1..=tries {
match self.udp_scan(socket, payload, self.timeout).await {
Ok(true) => return Ok(PortStatus::Open(socket)),
match self.udp_scan(socket, &payload, self.timeout).await {
Ok(true) => return Ok(socket),
Ok(false) => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::other(format!(
"UDP scan timed-out for all tries on socket {socket}"
)))
Err(io::Error::new(
io::ErrorKind::Other,
format!("UDP scan timed-out for all tries on socket {}", socket),
))
}
/// Performs the connection to the socket with timeout
@@ -400,7 +275,7 @@ impl Scanner {
match io::timeout(wait, udp_socket.recv(&mut buf)).await {
Ok(size) => {
debug!("Received {size} bytes");
debug!("Received {} bytes", size);
self.fmt_ports(socket);
Ok(true)
}
@@ -414,7 +289,7 @@ impl Scanner {
}
}
Err(e) => {
debug!("Error binding UDP socket: {e:?}");
println!("Err E binding sock {:?}", e);
Err(e)
}
}
@@ -422,22 +297,11 @@ impl Scanner {
/// Formats and prints the port status
fn fmt_ports(&self, socket: SocketAddr) {
if self.print_open_ports && !self.greppable {
if !self.greppable {
if self.accessible {
println_safe(format_args!("Open {socket}"));
println!("Open {socket}");
} else {
println_safe(format_args!("Open {}", socket.to_string().purple()));
}
}
}
/// Prints a closed port (CLI output only, never in greppable mode).
fn fmt_closed_port(&self, socket: SocketAddr) {
if self.print_open_ports && !self.greppable {
if self.accessible {
println_safe(format_args!("Closed {socket}"));
} else {
println_safe(format_args!("Closed {}", socket.to_string().red()));
println!("Open {}", socket.to_string().purple());
}
}
}
@@ -446,96 +310,218 @@ impl Scanner {
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{PortRange, ScanOrder};
use async_std::task::block_on;
use std::{net::IpAddr, time::Duration};
use crate::input::{PortRanges, ScanOrder};
// These tests never open sockets: they only build a `Scanner` or inspect
// the payload table generated by build.rs.
fn test_scanner() -> Scanner {
#[test]
fn scanner_runs() {
// Makes sure the program still runs and doesn't panic
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
let strategy = PortStrategy::pick(&Some(PortRanges(vec![(1, 1)])), None, ScanOrder::Serial);
Scanner::new(
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
1,
10,
Duration::from_millis(100),
1,
false,
true,
strategy,
true,
vec![9000],
false,
Vec::new(),
);
block_on(scanner.run());
// if the scan fails, it wouldn't be able to assert_eq! as it panicked!
assert_eq!(1, 1);
}
#[test]
fn ipv6_scanner_runs() {
// Makes sure the program still runs and doesn't panic
let addrs = vec!["::1".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
false,
)
);
block_on(scanner.run());
// if the scan fails, it wouldn't be able to assert_eq! as it panicked!
assert_eq!(1, 1);
}
#[test]
fn quad_zero_scanner_runs() {
let addrs = vec!["0.0.0.0".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
false,
);
block_on(scanner.run());
assert_eq!(1, 1);
}
#[test]
fn google_dns_runs() {
let addrs = vec!["8.8.8.8".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 400,
end: 445,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
false,
);
block_on(scanner.run());
assert_eq!(1, 1);
}
#[test]
fn infer_ulimit_lowering_no_panic() {
// Test behaviour on MacOS where ulimit is not automatically lowered
let addrs = vec!["8.8.8.8".parse::<IpAddr>().unwrap()];
// mac should have this automatically scaled down
let range = PortRange {
start: 400,
end: 600,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
false,
);
block_on(scanner.run());
assert_eq!(1, 1);
}
#[test]
fn library_scanner_is_quiet_by_default() {
let scanner = test_scanner();
assert!(!scanner.print_open_ports);
fn udp_scan_runs() {
// Makes sure the program still runs and doesn't panic
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
true,
);
block_on(scanner.run());
// if the scan fails, it wouldn't be able to assert_eq! as it panicked!
assert_eq!(1, 1);
}
#[test]
fn cli_can_enable_open_port_output() {
let scanner = test_scanner().with_open_port_output();
assert!(scanner.print_open_ports);
fn udp_ipv6_runs() {
// Makes sure the program still runs and doesn't panic
let addrs = vec!["::1".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
true,
);
block_on(scanner.run());
// if the scan fails, it wouldn't be able to assert_eq! as it panicked!
assert_eq!(1, 1);
}
#[test]
fn closed_ports_are_not_reported_by_default() {
assert!(!test_scanner().report_closed);
fn udp_quad_zero_scanner_runs() {
let addrs = vec!["0.0.0.0".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
true,
);
block_on(scanner.run());
assert_eq!(1, 1);
}
#[test]
fn closed_port_reporting_is_opt_in() {
assert!(test_scanner().with_closed_ports().report_closed);
}
#[test]
fn no_interval_by_default() {
assert!(test_scanner().interval.is_zero());
}
#[test]
fn with_interval_sets_the_delay_between_ports() {
let scanner = test_scanner().with_interval(Duration::from_millis(250));
assert_eq!(scanner.interval, Duration::from_millis(250));
}
/// Regression test for https://github.com/bee-san/RustScan/issues/933:
/// the SNMP public-walk probe must be the exact 33-byte BER packet, with
/// the literal `public` community string intact. The old hexdigits-only
/// decoding mangled it into a 28-byte probe that agents never answered.
#[test]
fn udp_snmp_probe_bytes_match_nmap() {
let payload = get_parsed_data()
.iter()
.find(|(ports, _)| ports.contains(&161))
.map(|(_, payload)| payload)
.expect("no UDP payload registered for port 161");
let expected: Vec<u8> = vec![
0x30, 0x1f, 0x02, 0x01, 0x00, 0x04, 0x06, b'p', b'u', b'b', b'l', b'i', b'c', 0xa1,
0x12, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x30, 0x07, 0x30, 0x05,
0x06, 0x01, 0x00, 0x05, 0x00,
];
assert_eq!(*payload, expected);
}
/// The SSDP probe mixes `\xNN` escapes, `\"` escapes and literal text
/// across two quoted segments: segments must decode and concatenate
/// with no separators.
#[test]
fn udp_ssdp_probe_decodes_escapes_and_literal_text() {
let payload = get_parsed_data()
.iter()
.find(|(ports, _)| ports.contains(&1900))
.map(|(_, payload)| payload)
.expect("no UDP payload registered for port 1900");
let expected =
b"M-SEARCH * HTTP/1.1\r\nHost: 239.255.255.250:1900\r\nMan: \"ssdp:discover\"\r\nMX: 5\r\nST: ssdp:all\r\n\r\n"
.to_vec();
assert_eq!(*payload, expected);
fn udp_google_dns_runs() {
let addrs = vec!["8.8.8.8".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 100,
end: 150,
};
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
strategy,
true,
vec![9000],
true,
);
block_on(scanner.run());
assert_eq!(1, 1);
}
}
+29 -101
View File
@@ -1,78 +1,55 @@
use itertools::{iproduct, Product};
use std::iter::FusedIterator;
use std::net::{IpAddr, SocketAddr};
use std::slice;
/// An iterator that receives a slice of IPs and ports and returns a
/// [`SocketAddr`] for each IP/port pair until all combinations are exhausted.
///
/// The goal of this iterator is to walk every IP and port combination
/// *without* allocating a big intermediate buffer — the alternative would be
/// materialising a `Vec<SocketAddr>` of every pair up front.
///
/// # Ordering
///
/// The IP/port order is intentionally reversed inside `product_it`: we want
/// `iproduct!` to iterate *all IPs for one port* before advancing to the next
/// port ("hold the port, go through all the IPs, then advance the port").
///
/// # Example
///
/// `SocketIterator` lives in a private module, so this example is not run as
/// a doctest; `goes_through_every_ip_port_combination` checks the same order.
///
/// ```ignore
/// # use std::net::IpAddr;
/// let ips = [
/// "127.0.0.1".parse::<IpAddr>().unwrap(),
/// "192.168.0.1".parse::<IpAddr>().unwrap(),
/// ];
/// let ports = [80u16, 443];
///
/// let mut it = SocketIterator::new(&ips, &ports);
/// assert_eq!(it.next(), Some("127.0.0.1:80".parse().unwrap()));
/// assert_eq!(it.next(), Some("192.168.0.1:80".parse().unwrap()));
/// assert_eq!(it.next(), Some("127.0.0.1:443".parse().unwrap()));
/// assert_eq!(it.next(), Some("192.168.0.1:443".parse().unwrap()));
/// assert_eq!(it.next(), None);
/// ```
#[derive(Clone)]
pub struct SocketIterator<'s> {
// No boxing: the iterator owns the concrete slice iterators directly.
// This keeps `SocketIterator` allocation-free and makes `Clone` free too.
product_it: Product<slice::Iter<'s, u16>, slice::Iter<'s, IpAddr>>,
// product_it is a cartesian product iterator over
// the slices of ports and IP addresses.
//
// The IP/port order is intentionally reversed here since we want
// the itertools::iproduct! macro below to generate the pairs with
// all the IPs for one port before moving on to the next one
// ("hold the port, go through all the IPs, then advance the port...").
// See also the comments in the iterator implementation for an example.
product_it:
Product<Box<std::slice::Iter<'s, u16>>, Box<std::slice::Iter<'s, std::net::IpAddr>>>,
}
/// An iterator that receives a slice of IPs and ports and returns a Socket
/// for each IP and port pair until all of these combinations are exhausted.
/// The goal of this iterator is to go over every IP and port combination
/// without generating a big memory footprint. The alternative would be
/// generating a vector containing all these combinations.
impl<'s> SocketIterator<'s> {
pub fn new(ips: &'s [IpAddr], ports: &'s [u16]) -> Self {
let ports_it = Box::new(ports.iter());
let ips_it = Box::new(ips.iter());
Self {
// `iproduct!` calls `.into_iter()` on each argument; `slice::Iter`
// is already an iterator, so this is a no-op (no allocation).
product_it: iproduct!(ports.iter(), ips.iter()),
product_it: iproduct!(ports_it, ips_it),
}
}
}
#[allow(clippy::doc_link_with_quotes)]
impl Iterator for SocketIterator<'_> {
type Item = SocketAddr;
/// Returns the next socket, or `None` when all combinations are exhausted.
/// Every IP is paired with the same port until the port advances.
/// Returns a socket based on the combination of one of the provided
/// IPs and ports or None when these combinations are exhausted. Every
/// IP will have the same port until a port is incremented.
///
/// let it = SocketIterator::new(&["127.0.0.1", "192.168.0.1"], &[80, 443]);
/// it.next(); // 127.0.0.1:80
/// it.next(); // 192.168.0.1:80
/// it.next(); // 127.0.0.1:443
/// it.next(); // 192.168.0.1:443
/// it.next(); // None
fn next(&mut self) -> Option<Self::Item> {
self.product_it
.next()
.map(|(port, ip)| SocketAddr::new(*ip, *port))
}
// Delegate the size hint so callers like `collect()` can pre-size buffers.
fn size_hint(&self) -> (usize, Option<usize>) {
self.product_it.size_hint()
}
}
// `Product` and `slice::Iter` are both fused, so we are too.
impl FusedIterator for SocketIterator<'_> {}
#[cfg(test)]
mod tests {
use super::SocketIterator;
@@ -95,53 +72,4 @@ mod tests {
assert_eq!(Some(SocketAddr::new(addrs[1], ports[2])), it.next());
assert_eq!(None, it.next());
}
#[test]
fn size_hint_is_exact() {
let addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
let ports: Vec<u16> = vec![22, 80, 443];
let mut it = SocketIterator::new(&addrs, &ports);
assert_eq!(it.size_hint(), (3, Some(3)));
it.next();
assert_eq!(it.size_hint(), (2, Some(2)));
}
#[test]
fn clone_resumes_independently() {
let addrs = [
"127.0.0.1".parse::<IpAddr>().unwrap(),
"192.168.0.1".parse::<IpAddr>().unwrap(),
];
let ports: Vec<u16> = vec![22, 80];
let mut a = SocketIterator::new(&addrs, &ports);
assert_eq!(a.next(), Some(SocketAddr::new(addrs[0], ports[0])));
let b = a.clone(); // b starts where a is now
// 2 IPs x 2 ports = 4 sockets, one of which was consumed before cloning.
let rest = [
SocketAddr::new(addrs[1], ports[0]),
SocketAddr::new(addrs[0], ports[1]),
SocketAddr::new(addrs[1], ports[1]),
];
assert_eq!(a.collect::<Vec<_>>(), rest);
assert_eq!(b.collect::<Vec<_>>(), rest);
}
#[test]
fn empty_inputs_yield_nothing() {
let addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
let ports = [22u16];
for mut it in [
SocketIterator::new(&[], &ports),
SocketIterator::new(&addrs, &[]),
] {
assert_eq!(it.size_hint(), (0, Some(0)));
assert_eq!(it.next(), None);
// Stays exhausted, as promised by the `FusedIterator` impl.
assert_eq!(it.next(), None);
}
}
}
+88 -19
View File
@@ -46,9 +46,12 @@
//!
//! - `fixtures/test_rustscan_scripts.toml`
//!
//! Metadata-only test fixture:
//! Script file examples:
//!
//! - `fixtures/.rustscan_scripts/test_script.txt`
//! - `fixtures/test_script.py`
//! - `fixtures/test_script.pl`
//! - `fixtures/test_script.sh`
//! - `fixtures/test_script.txt`
//!
//! `call_format` in script files can be of 2 variants:
//!
@@ -106,7 +109,7 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
}
ScriptsRequired::Custom => {
let script_config = ScriptConfig::read_config()?;
debug!("Script config \n{script_config:?}");
debug!("Script config \n{:?}", script_config);
let script_dir_base = if let Some(config_directory) = &script_config.directory {
PathBuf::from(config_directory)
@@ -115,10 +118,10 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
};
let script_paths = find_scripts(script_dir_base)?;
debug!("Scripts paths \n{script_paths:?}");
debug!("Scripts paths \n{:?}", script_paths);
let parsed_scripts = parse_scripts(script_paths);
debug!("Scripts parsed \n{parsed_scripts:?}");
debug!("Scripts parsed \n{:?}", parsed_scripts);
// Only Scripts that contain all the tags found in ScriptConfig will be selected.
if let Some(config_hashset) = script_config.tags {
@@ -132,14 +135,14 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
} else {
debug!(
"\nScript tags does not match config tags {:?} {}",
script_hashset,
&script_hashset,
script.path.unwrap().display()
);
}
}
}
}
debug!("\nScript(s) to run {scripts_to_run:?}");
debug!("\nScript(s) to run {:?}", scripts_to_run);
}
}
@@ -149,7 +152,7 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
pub fn parse_scripts(scripts: Vec<PathBuf>) -> Vec<ScriptFile> {
let mut parsed_scripts: Vec<ScriptFile> = Vec::with_capacity(scripts.len());
for script in scripts {
debug!("Parsing script {}", script.display());
debug!("Parsing script {}", &script.display());
if let Some(script_file) = ScriptFile::new(script) {
parsed_scripts.push(script_file);
}
@@ -221,7 +224,7 @@ impl Script {
// Some variables get changed before read, and compiler throws warning on warn(unused_assignments)
#[allow(unused_assignments)]
pub fn run(self) -> Result<String> {
debug!("run self {:?}", self);
debug!("run self {:?}", &self);
let separator = self.ports_separator.unwrap_or_else(|| ",".into());
@@ -266,14 +269,14 @@ impl Script {
};
to_run = default_template.fill_with_struct(&exec_parts)?;
}
debug!("\nScript format to run {to_run}");
debug!("\nScript format to run {}", to_run);
execute_script(&to_run)
}
}
#[cfg(not(tarpaulin_include))]
fn execute_script(script: &str) -> Result<String> {
debug!("\nScript arguments {script}");
debug!("\nScript arguments {}", script);
let (cmd, arg) = if cfg!(unix) {
("sh", "-c")
@@ -311,7 +314,7 @@ fn execute_script(script: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
Err(error) => {
debug!("Command error {error}",);
debug!("Command error {}", error.to_string());
Err(anyhow!(error.to_string()))
}
}
@@ -319,7 +322,7 @@ fn execute_script(script: &str) -> Result<String> {
pub fn find_scripts(path: PathBuf) -> Result<Vec<PathBuf>> {
if path.is_dir() {
debug!("Scripts folder found {}", path.display());
debug!("Scripts folder found {}", &path.display());
let mut files_vec: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
@@ -357,20 +360,20 @@ impl ScriptFile {
}
}
} else {
debug!("Failed to read file: {}", real_path.display());
debug!("Failed to read file: {}", &real_path.display());
return None;
}
debug!("ScriptFile {} lines\n{}", real_path.display(), lines_buf);
debug!("ScriptFile {} lines\n{}", &real_path.display(), &lines_buf);
match toml::from_str::<ScriptFile>(&lines_buf) {
Ok(mut parsed) => {
debug!("Parsed ScriptFile{} \n{:?}", real_path.display(), parsed);
debug!("Parsed ScriptFile{} \n{:?}", &real_path.display(), &parsed);
parsed.path = Some(real_path);
// parsed_scripts.push(parsed);
Some(parsed)
}
Err(e) => {
debug!("Failed to parse ScriptFile headers {e}");
debug!("Failed to parse ScriptFile headers {}", e.to_string());
None
}
}
@@ -403,11 +406,25 @@ impl ScriptConfig {
mod tests {
use super::*;
// Function for testing only, it inserts static values into ip and open_ports
// Doesn't use impl in case it's implemented in the super module at some point
fn into_script(script_f: ScriptFile) -> Script {
Script::build(
script_f.path,
"127.0.0.1".parse().unwrap(),
vec![80, 8080],
script_f.port,
script_f.ports_separator,
script_f.tags,
script_f.call_format,
)
}
#[test]
fn find_and_parse_scripts() {
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
let scripts = parse_scripts(scripts);
assert_eq!(scripts.len(), 1);
assert_eq!(scripts.len(), 4);
}
#[test]
@@ -423,6 +440,26 @@ mod tests {
.unwrap();
}
#[test]
#[should_panic]
fn open_script_file_invalid_call_format() {
let mut script_f =
ScriptFile::new("fixtures/.rustscan_scripts/test_script.txt".into()).unwrap();
script_f.call_format = Some("qwertyuiop".to_string());
let script: Script = into_script(script_f);
let _output = script.run().unwrap();
}
#[test]
#[should_panic]
fn open_script_file_missing_call_format() {
let mut script_f =
ScriptFile::new("fixtures/.rustscan_scripts/test_script.txt".into()).unwrap();
script_f.call_format = None;
let script: Script = into_script(script_f);
let _output = script.run().unwrap();
}
#[test]
#[should_panic]
fn open_nonexisting_script_file() {
@@ -447,10 +484,42 @@ mod tests {
assert_eq!(script_f.ports_separator, Some(",".to_string()));
assert_eq!(
script_f.call_format,
Some("fixture {{ip}} {{port}}".to_string())
Some("nmap -vvv -p {{port}} {{ip}}".to_string())
);
}
#[test]
#[cfg(unix)]
fn run_bash_script() {
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.sh".into()).unwrap();
let script: Script = into_script(script_f);
let output = script.run().unwrap();
// output has a newline at the end by default, .trim() trims it
assert_eq!(output.trim(), "127.0.0.1 80,8080");
}
#[test]
fn run_python_script() {
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.py".into()).unwrap();
let script: Script = into_script(script_f);
let output = script.run().unwrap();
// output has a newline at the end by default, .trim() trims it
assert_eq!(
output.trim(),
"Python script ran with arguments ['fixtures/.rustscan_scripts/test_script.py', '127.0.0.1', '80,8080']"
);
}
#[test]
#[cfg(unix)]
fn run_perl_script() {
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.pl".into()).unwrap();
let script: Script = into_script(script_f);
let output = script.run().unwrap();
// output has a newline at the end by default, .trim() trims it
assert_eq!(output.trim(), "Total args passed to fixtures/.rustscan_scripts/test_script.pl : 2\nArg # 1 : 127.0.0.1\nArg # 2 : 80,8080");
}
#[test]
fn test_custom_directory_config() {
// Create test config
+13 -57
View File
@@ -1,55 +1,20 @@
//! Utilities for terminal output during scanning.
use std::io::Write;
/// Prints a single line to stdout without ever panicking.
///
/// `println!` panics when writing to stdout fails, which is fatal under the
/// `panic = "abort"` release profile. That includes the `BrokenPipe` error
/// produced when a downstream consumer (e.g. `rustscan -g ... | head`)
/// closes the pipe, so instead the process exits quietly when that happens.
/// Other output errors are reported on stderr before exiting.
///
/// All user-facing output should go through this function (the `warning!`,
/// `detail!`, `output!` and `funny_opening!` macros already do) instead of
/// `println!`.
pub fn println_safe(args: std::fmt::Arguments<'_>) {
match writeln!(std::io::stdout().lock(), "{args}") {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
// the reader end of the pipe is gone; nothing left to print to
std::process::exit(0);
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "rustscan: failed writing to stdout: {e}");
std::process::exit(1);
}
}
}
/// Terminal User Interface Module for RustScan
/// Defines macros to use
#[macro_export]
macro_rules! warning {
($name:expr) => {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Red.bold().paint("[!]"),
$name
));
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
$crate::tui::println_safe(format_args!("{}", $name));
println!("{}", $name);
} else {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Red.bold().paint("[!]"),
$name
));
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
}
}
};
@@ -58,24 +23,16 @@ macro_rules! warning {
#[macro_export]
macro_rules! detail {
($name:expr) => {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Blue.bold().paint("[~]"),
$name
));
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
$crate::tui::println_safe(format_args!("{}", $name));
println!("{}", $name);
} else {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Blue.bold().paint("[~]"),
$name
));
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
}
}
};
@@ -84,24 +41,24 @@ macro_rules! detail {
#[macro_export]
macro_rules! output {
($name:expr) => {
$crate::tui::println_safe(format_args!(
println!(
"{} {}",
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
RGansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
$name
));
);
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
$crate::tui::println_safe(format_args!("{}", $name));
println!("{}", $name);
} else {
$crate::tui::println_safe(format_args!(
println!(
"{} {}",
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
$name
));
);
}
}
};
@@ -140,10 +97,9 @@ macro_rules! funny_opening {
"RustScan: Exploring the digital landscape, one IP at a time.",
"TreadStone was here 🚀",
"With RustScan, I scan ports so fast, even my firewall gets whiplash 💨",
"Scanning ports so fast, even the internet got a speeding ticket!",
];
let random_quote = quotes.choose(&mut rand::rng()).unwrap();
$crate::tui::println_safe(format_args!("{}\n", random_quote));
println!("{}\n", random_quote);
};
}
+163
View File
@@ -0,0 +1,163 @@
/*
* Test rustscan against different targets with a time limit.
* The tests assumes target/debug/rustscan has already been built.
*
* The tests are #[ignore] to avoid running them during normal development.
*
* Their tests in the timelimits module are run by travis during CI.
*/
use std::process::Command;
use std::time::Duration;
use wait_timeout::ChildExt;
const TIMEOUT_MARGIN: u32 = 3;
#[cfg(not(tarpaulin_include))]
fn run_rustscan_with_timeout(args: &[&str], timeout: Duration) {
println!("Running: target/debug/rustscan: {}", args.join(" "));
use std::time::Instant;
let start = Instant::now();
let mut child = Command::new("target/debug/rustscan")
.args(args)
.spawn()
.unwrap();
let mut tries = TIMEOUT_MARGIN;
loop {
match child.wait_timeout(timeout).unwrap() {
Some(_status) => break,
None => {
tries -= 1;
if tries == 0 {
// child hasn't exited yet
child.kill().unwrap();
child.wait().unwrap().code().unwrap();
panic!("Timeout while running command");
}
}
}
}
let end = Instant::now();
let duration = end.saturating_duration_since(start).as_secs_f32();
println!("time: {:1.1}s", duration);
}
mod timelimits {
#[test]
#[ignore]
fn scan_localhost() {
let timeout = super::Duration::from_secs(25);
super::run_rustscan_with_timeout(&["--greppable", "--no-nmap", "127.0.0.1"], timeout);
}
#[test]
#[ignore]
fn scan_google_com() {
super::run_rustscan_with_timeout(
&[
"--greppable",
"--no-nmap",
"-u",
"5000",
"-b",
"2500",
"google.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn scan_example_com() {
super::run_rustscan_with_timeout(
&[
"--greppable",
"--no-nmap",
"-u",
"5000",
"-b",
"2500",
"example.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn scan_rustscan_cmnatic_co_uk() {
super::run_rustscan_with_timeout(
&[
"--greppable",
"--no-nmap",
"-u",
"5000",
"-b",
"2500",
"rustscan.cmnatic.co.uk",
],
super::Duration::from_secs(26),
);
}
#[test]
#[ignore]
fn udp_scan_localhost() {
let timeout = super::Duration::from_secs(25);
super::run_rustscan_with_timeout(&["--greppable", "127.0.0.1", "--udp"], timeout);
}
#[test]
#[ignore]
fn udp_scan_google_com() {
super::run_rustscan_with_timeout(
&[
"--udp",
"--greppable",
"-u",
"5000",
"-b",
"2500",
"google.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn udp_scan_example_com() {
super::run_rustscan_with_timeout(
&[
"--udp",
"--greppable",
"-u",
"5000",
"-b",
"2500",
"example.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn udp_scan_rustscan_cmnatic_co_uk() {
super::run_rustscan_with_timeout(
&[
"--udp",
"--greppable",
"-u",
"5000",
"-b",
"2500",
"rustscan.cmnatic.co.uk",
],
super::Duration::from_secs(26),
);
}
}
-32
View File
@@ -1,32 +0,0 @@
use rustscan::generated::get_parsed_data;
use rustscan::scanner::build_udp_payload_lookup;
#[test]
fn udp_payload_lookup_contains_common_udp_ports() {
let udp_map = get_parsed_data();
let lookup = build_udp_payload_lookup(udp_map);
// These are common UDP services; the payload database should include them.
assert!(
lookup.contains_key(&53),
"expected UDP payload for DNS (53)"
);
assert!(
lookup.contains_key(&123),
"expected UDP payload for NTP (123)"
);
}
#[test]
fn udp_payload_lookup_payloads_are_non_empty_for_known_ports() {
let udp_map = get_parsed_data();
let lookup = build_udp_payload_lookup(udp_map);
// Don't assert exact bytes (the generated payload set may evolve),
// but it should not be empty for these well-known protocols.
let dns = lookup.get(&53).expect("missing DNS payload");
assert!(!dns.is_empty(), "DNS payload should not be empty");
let ntp = lookup.get(&123).expect("missing NTP payload");
assert!(!ntp.is_empty(), "NTP payload should not be empty");
}