use super::*;
use gossan_core::NetworkTarget;
use std::net::IpAddr;
use std::time::Duration;
use tokio::net::TcpListener;
fn service(port: u16, banner: Option<&str>) -> ServiceTarget {
ServiceTarget {
host: HostTarget {
ip: IpAddr::from([127, 0, 0, 1]),
domain: Some("example.com".into()),
},
port,
protocol: Protocol::Tcp,
banner: banner.map(str::to_string),
tls: matches!(port, 443 | 465 | 636 | 993 | 8443),
}
}
#[test]
fn scanner_accepts_domains_hosts_and_networks() {
let scanner = PortScanner;
assert!(scanner.accepts(&Target::Domain(DomainTarget {
domain: "example.com".into(),
source: DiscoverySource::Seed,
})));
assert!(scanner.accepts(&Target::Host(HostTarget {
ip: IpAddr::from([127, 0, 0, 1]),
domain: None,
})));
assert!(scanner.accepts(&Target::Network(NetworkTarget {
cidr: "1.2.3.0/24".into(),
source: DiscoverySource::Asn,
})));
assert!(!scanner.accepts(&Target::Service(service(443, None))));
}
#[test]
fn risky_ports_list_contains_high_value_targets() {
for port in [23, 2375, 6379, 9200, 10250, 30303] {
assert!(
rules::risky_services().iter().any(|r| r.port == port),
"missing risky port {port}"
);
}
}
#[test]
fn identify_banner_detects_old_ssh_versions_as_high() {
let finding = identify_banner_or_probe(
"SSH-2.0-OpenSSH_7.2p1 Ubuntu-4ubuntu2.10",
&[], &service(22, None),
22,
)
.unwrap();
assert_eq!(finding.severity(), Severity::High);
assert!(finding.title().contains("SSH version disclosed"));
assert!(finding.tags().iter().any(|t| t.as_ref() == "ssh"));
}
#[test]
fn identify_banner_detects_modern_ssh_versions_as_info() {
let finding =
identify_banner_or_probe("SSH-2.0-OpenSSH_9.7", &[], &service(22, None), 22).unwrap();
assert_eq!(finding.severity(), Severity::Info);
}
#[test]
fn identify_banner_detects_ftp_banner() {
let finding =
identify_banner_or_probe("220 ProFTPD 1.3.5 Server", &[], &service(21, None), 21).unwrap();
assert!(finding.title().contains("FTP banner"));
assert!(finding.tags().iter().any(|t| t.as_ref() == "ftp"));
}
#[test]
fn identify_banner_detects_smtp_banner() {
let finding = identify_banner_or_probe(
"220 mx.example.com ESMTP Postfix",
&[],
&service(25, None),
25,
)
.unwrap();
assert!(finding.title().contains("SMTP banner"));
assert!(finding.tags().iter().any(|t| t.as_ref() == "smtp"));
}
#[test]
fn identify_banner_extracts_http_server_header() {
let banner = "HTTP/1.1 200 OK\r\nServer: nginx/1.18.0\r\n\r\n";
let finding = identify_banner_or_probe(banner, &[], &service(80, None), 80).unwrap();
assert!(finding.title().contains("HTTP server header"));
assert!(finding.detail().contains("discloses"));
}
#[test]
fn identify_banner_detects_redis_no_auth() {
let finding = identify_banner_or_probe("+PONG", &[], &service(6379, None), 6379).unwrap();
assert_eq!(finding.severity(), Severity::Critical);
assert!(finding
.title()
.contains("Redis responds without authentication"));
}
#[test]
fn identify_banner_detects_mongodb_no_auth() {
let finding =
identify_banner_or_probe("ismaster MongoDB", &[], &service(27017, None), 27017).unwrap();
assert_eq!(finding.severity(), Severity::Critical);
assert!(finding.tags().iter().any(|t| t.as_ref() == "mongodb"));
}
#[test]
fn identify_banner_detects_telnet_response() {
let finding = identify_banner_or_probe("Welcome", &[], &service(23, None), 23).unwrap();
assert_eq!(finding.severity(), Severity::Critical);
assert!(finding.tags().iter().any(|t| t.as_ref() == "telnet"));
}
#[test]
fn identify_banner_returns_none_for_unrecognized_banner() {
assert!(
identify_banner_or_probe("some random banner", &[], &service(1234, None), 1234).is_none()
);
}
#[test]
fn port_mode_custom_can_represent_explicit_ranges() {
let mode = PortMode::Custom(vec![1, 80, 443, 65535]);
let PortMode::Custom(ports) = mode else {
panic!("expected custom mode");
};
assert_eq!(ports, vec![1, 80, 443, 65535]);
}
#[test]
fn top_ports_tables_are_nonempty_and_unique() {
use std::collections::HashSet;
assert!(!rules::top_100().is_empty());
assert!(!rules::top_1000().is_empty());
assert_eq!(
rules::top_100().iter().collect::<HashSet<_>>().len(),
rules::top_100().len()
);
assert_eq!(
rules::top_1000().iter().collect::<HashSet<_>>().len(),
rules::top_1000().len()
);
}
#[test]
fn default_portset_includes_common_web_and_database_ports() {
let ports = rules::default_ports();
for port in [80, 443, 5432, 6379, 8080] {
assert!(ports.contains(&port), "missing default port {port}");
}
}
#[tokio::test]
async fn port_zero_is_handled_gracefully() {
let scanner = PortScanner::new();
assert!(scanner.accepts(&Target::Domain(DomainTarget {
domain: "localhost".into(),
source: DiscoverySource::Seed,
})));
}
#[test]
fn port_maximum_valid_u16() {
let max_port: u16 = 65535;
assert_eq!(max_port, u16::MAX);
let mode = PortMode::Custom(vec![65535]);
match mode {
PortMode::Custom(ports) => {
assert_eq!(ports, vec![65535]);
}
_ => panic!("expected custom mode"),
}
}
#[tokio::test]
async fn connection_timeout_respected() {
let start = std::time::Instant::now();
let timeout_duration = Duration::from_millis(100);
let _result = tokio::time::timeout(
timeout_duration * 2,
gossan_core::net::connect_tcp("192.0.2.1", 9999, None),
)
.await;
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"Connection attempt took too long: {:?}",
elapsed
);
}
#[tokio::test]
async fn banner_grab_times_out_on_silent_server() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let local_addr = listener.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
tokio::time::sleep(Duration::from_secs(10)).await;
drop(socket);
});
let stream = tokio::net::TcpStream::connect(local_addr).await.unwrap();
let start = std::time::Instant::now();
let banner = grab_banner(stream, Duration::from_millis(500)).await;
let elapsed = start.elapsed();
assert!(banner.is_none(), "Expected None for silent server");
assert!(
elapsed < Duration::from_secs(2),
"Banner grab took too long to timeout: {:?}",
elapsed
);
server_task.abort();
}
#[test]
fn empty_port_list_handled() {
let mode = PortMode::Custom(vec![]);
match mode {
PortMode::Custom(ports) => {
assert!(ports.is_empty());
}
_ => panic!("expected custom mode"),
}
}
#[test]
fn full_port_range_generation() {
let full_range: Vec<u16> = (1u16..=65535).collect();
assert_eq!(full_range.len(), 65535);
assert_eq!(full_range[0], 1);
assert_eq!(full_range[65534], 65535);
}
#[test]
fn banner_processing_handles_large_input() {
let svc = service(22, None);
let large_banner = "A".repeat(2000);
let finding = identify_banner_or_probe(&large_banner, &[], &svc, 22);
assert!(finding.is_none());
}
#[test]
fn banner_sanitizes_binary_data() {
let binary_data: Vec<u8> = vec![
0x00, 0x01, 0x02, 0x03, 0x20, 0x21, 0x22, 0x7f, 0x80, 0xff, ];
let sanitized: String = binary_data
.iter()
.map(|&b| {
if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
}
})
.collect();
assert_eq!(sanitized, ".... !\"...");
}
#[test]
fn edge_case_port_numbers() {
let edge_ports = [1, 1024, 1025, 65534, 65535];
for port in edge_ports {
let svc = service(port, None);
assert_eq!(svc.port, port);
}
}
#[test]
fn finding_builder_edge_cases() {
let target = Target::Domain(DomainTarget {
domain: "test.example.com".into(),
source: DiscoverySource::Seed,
});
let finding = finding_builder(&target, Severity::Info, "test", "test detail").build();
assert!(finding.is_ok());
let long_string = "x".repeat(10000);
let finding = finding_builder(&target, Severity::High, &long_string, &long_string).build();
assert!(finding.is_ok());
let unicode = "ๆต่ฏ ๐ รฉmoji ๆฅๆฌ่ช";
let finding = finding_builder(&target, Severity::Medium, unicode, unicode).build();
assert!(finding.is_ok());
}
#[test]
fn cve_correlation_edge_cases() {
use crate::cve::correlate;
let svc = service(80, None);
let findings = correlate("", &svc);
assert!(findings.is_empty());
let long_banner = format!("Server: Apache/2.4.49 {}", "x".repeat(10000));
let findings = correlate(&long_banner, &svc);
assert!(findings
.iter()
.any(|f| f.title().contains("CVE-2021-41773")));
let findings = correlate("SERVER: APACHE/2.4.49", &svc);
assert!(findings
.iter()
.any(|f| f.title().contains("CVE-2021-41773")));
}
#[test]
fn tls_cert_info_display() {
use crate::tls::{days_until_expiry, LegacyTlsResult, TlsCertInfo};
let info = TlsCertInfo {
subject: "CN=test.com".into(),
issuer: "CN=Test CA".into(),
sans: vec!["test.com".into(), "www.test.com".into()],
not_after_unix: 1893456000,
is_self_signed: false,
cipher_suite: "TLS13_AES_256_GCM_SHA384".into(),
protocol_version: "TLS1.3".into(),
};
let display = format!("{}", info);
assert!(display.contains("TlsCertInfo"));
assert!(display.contains("test.com"));
assert!(display.contains("self-signed: false"));
let legacy = LegacyTlsResult {
supports_tls10: true,
supports_tls11: false,
};
let display = format!("{}", legacy);
assert!(display.contains("VULNERABLE"));
let legacy_clean = LegacyTlsResult {
supports_tls10: false,
supports_tls11: false,
};
let display = format!("{}", legacy_clean);
assert!(display.contains("no legacy protocols"));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
assert_eq!(days_until_expiry(now), 0);
assert!(days_until_expiry(now + 365 * 86400) > 364);
assert!(days_until_expiry(now - 365 * 86400) < -364);
}
#[test]
fn test_scan_target_key_serialization_and_legacy_migration() {
use std::collections::HashSet;
use std::net::IpAddr;
let mut completed = HashSet::new();
completed.insert(ScanTargetKey {
target: "alpha.com".to_string(),
port: 80,
});
completed.insert(ScanTargetKey {
target: "127.0.0.1".to_string(),
port: 443,
});
let keys: Vec<&ScanTargetKey> = completed.iter().collect();
let json = serde_json::to_string(&keys).unwrap();
let parsed: Vec<ScanTargetKey> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.len(), 2);
assert!(parsed
.iter()
.any(|k| k.target == "alpha.com" && k.port == 80));
assert!(parsed
.iter()
.any(|k| k.target == "127.0.0.1" && k.port == 443));
let old_data: Vec<(IpAddr, u16)> = vec![
(IpAddr::from([1, 1, 1, 1]), 80),
(IpAddr::from([8, 8, 8, 8]), 53),
];
let old_json = serde_json::to_string(&old_data).unwrap();
let parsed_new_attempt = serde_json::from_str::<Vec<ScanTargetKey>>(&old_json);
if let Ok(ref migrated) = parsed_new_attempt {
assert_eq!(migrated.len(), 2);
assert!(migrated
.iter()
.any(|k| k.target == "1.1.1.1" && k.port == 80));
}
let old_ports: Vec<(IpAddr, u16)> = serde_json::from_str(&old_json).unwrap();
let migrated_keys: Vec<ScanTargetKey> = old_ports
.into_iter()
.map(|(ip, port)| ScanTargetKey {
target: ip.to_string(),
port,
})
.collect();
assert_eq!(migrated_keys.len(), 2);
assert!(migrated_keys
.iter()
.any(|k| k.target == "1.1.1.1" && k.port == 80));
assert!(migrated_keys
.iter()
.any(|k| k.target == "8.8.8.8" && k.port == 53));
}
#[test]
fn large_ipv6_network_does_not_panic_on_host_count() {
let net: ipnet::IpNet = "::/0".parse().unwrap();
let max_hosts: usize = 256;
let total_hosts = net.hosts().take(max_hosts + 1).count();
assert_eq!(total_hosts, max_hosts + 1);
}
#[test]
fn tls_ports_well_known_set_pinned() {
let expected: std::collections::HashSet<u16> = [443, 8443, 465, 993, 636, 995, 587]
.iter()
.copied()
.collect();
let actual: std::collections::HashSet<u16> = TLS_PORTS.iter().copied().collect();
assert_eq!(
expected, actual,
"TLS_PORTS diverged from the pinned set, update this test intentionally"
);
}
#[test]
fn ephemeral_port_constants_in_valid_range() {
assert!(EPHEMERAL_PORT_START >= 49152, "start below IANA minimum");
assert!(
(EPHEMERAL_PORT_START as u32) + (EPHEMERAL_PORT_COUNT as u32) <= 65535,
"start+count overflows u16 port space"
);
}
#[test]
fn probe_retry_constants_sane() {
assert!(PROBE_MAX_RETRIES > 0, "zero retries means we never probe");
assert!(
PROBE_MAX_RETRIES <= 10,
"excessive retries inflate scan time"
);
assert!(BACKOFF_TIMEOUT_BASE_MS > 0, "zero base delay causes spin");
assert!(
BACKOFF_TIMEOUT_BASE_MS <= 5_000,
"base delay > 5 s is too slow"
);
}
#[test]
fn probe_retry_backoff_uses_canonical_timeout_schedule() {
let backoff = probe_retry_backoff();
assert_eq!(backoff.max_retries(), PROBE_MAX_RETRIES);
assert_eq!(
backoff.delay(BackoffKind::Timeout, 0),
Duration::from_millis(200)
);
assert_eq!(
backoff.delay(BackoffKind::Timeout, 1),
Duration::from_millis(400)
);
assert!(!backoff.should_retry_after(PROBE_MAX_RETRIES - 1));
}