pub mod config;
pub mod mass;
pub mod aggregation;
pub mod inconsistency;
pub mod multi_ip;
pub mod phases;
pub use config::{CertificateConfig, CipherTestConfig, ProtocolTestConfig};
pub use phases::{ScanProgressReporter, SilentProgressReporter, TerminalProgressReporter};
use crate::certificates::{
parser::CertificateChain, revocation::RevocationResult, validator::ValidationResult,
};
use crate::ciphers::tester::ProtocolCipherSummary;
use crate::client_sim::simulator::ClientSimulationResult;
use crate::http::tester::HeaderAnalysisResult;
use crate::protocols::{Protocol, ProtocolTestResult};
use crate::rating::{RatingCalculator, RatingResult};
use crate::utils::mtls::MtlsConfig;
use crate::utils::network::Target;
use crate::vulnerabilities::{VulnerabilityResult, merge_vulnerability_result};
use crate::{Args, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
pub struct Scanner {
pub args: Args,
target: Arc<RwLock<Target>>,
mtls_config: Option<MtlsConfig>,
reporter: Arc<dyn phases::ScanProgressReporter>,
}
impl Scanner {
pub fn new(args: Args) -> Result<Self> {
Self::with_reporter(args, Arc::new(phases::TerminalProgressReporter::new()))
}
pub fn with_reporter(
args: Args,
reporter: Arc<dyn phases::ScanProgressReporter>,
) -> Result<Self> {
let target_str = args
.target
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No target specified"))?;
let placeholder_ip: std::net::IpAddr = "0.0.0.0".parse().unwrap();
let target = Target::with_ips(
target_str.to_string(),
args.port.unwrap_or(443),
vec![placeholder_ip],
)?;
let mtls_config = if let (Some(key_path), Some(cert_path)) =
(&args.tls.client_key, &args.tls.client_certs)
{
Some(MtlsConfig::from_separate_files(
cert_path,
key_path,
args.tls.client_key_password.as_deref(),
)?)
} else if let Some(mtls_path) = &args.tls.mtls_cert {
Some(MtlsConfig::from_pem_file(mtls_path)?)
} else {
None
};
Ok(Self {
args,
target: Arc::new(RwLock::new(target)),
mtls_config,
reporter,
})
}
pub async fn initialize(&self) -> Result<()> {
let target_str = self
.args
.target
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No target specified"))?;
let parsed_target = Target::parse(target_str).await?;
*self.target.write() = parsed_target;
Ok(())
}
fn get_target_owned(&self) -> Target {
self.target.read().clone()
}
pub fn set_target(&self, target: Target) {
*self.target.write() = target;
}
pub async fn run_with_phases(&self) -> Result<ScanResults> {
let start_time = Instant::now();
let placeholder_ip: std::net::IpAddr = "0.0.0.0".parse().unwrap();
let needs_init = {
let target = self.target.read();
target.ip_addresses.len() == 1 && target.ip_addresses[0] == placeholder_ip
};
if needs_init {
self.initialize().await?;
}
let should_run_multi_ip = {
let target = self.target.read();
target.ip_addresses.len() > 1 && !self.args.network.first_ip_only
};
if should_run_multi_ip {
return self.run_multi_ip_scan().await;
}
let target = self.get_target_owned();
let args = Arc::new(self.args.clone());
let context = phases::ScanContext::new(target, args, self.mtls_config.clone());
let orchestrator = phases::PhaseOrchestrator::with_reporter(Arc::clone(&self.reporter))
.add_phase(Box::new(phases::ProtocolPhase::new()))
.add_phase(Box::new(phases::CipherPhase::new()))
.add_phase(Box::new(phases::CertificatePhase::new()))
.add_phase(Box::new(phases::VulnerabilityPhase::new()))
.add_phase(Box::new(phases::HttpHeadersPhase::new()))
.add_phase(Box::new(phases::FingerprintPhase::new()))
.add_phase(Box::new(phases::ClientSimPhase::new()))
.add_phase(Box::new(phases::SignaturePhase::new()))
.add_phase(Box::new(phases::GroupsPhase::new()))
.add_phase(Box::new(phases::ClientCasPhase::new()))
.add_phase(Box::new(phases::IntolerancePhase::new()))
.add_phase(Box::new(phases::AlpnPhase::new()));
let mut results = orchestrator.execute(context).await?;
if self.args.scan.all || self.args.target.is_some() {
let rating_result = self.calculate_rating(&results);
results.rating = Some(RatingResults {
ssl_rating: Some(rating_result),
});
}
results.scan_time_ms = start_time.elapsed().as_millis() as u64;
Ok(results)
}
pub async fn run(&self) -> Result<ScanResults> {
self.run_with_phases().await
}
fn calculate_rating(&self, results: &ScanResults) -> RatingResult {
let cert_validation = results.certificate_chain.as_ref().map(|c| &c.validation);
RatingCalculator::calculate(
&results.protocols,
&results.ciphers,
cert_validation,
&results.vulnerabilities,
)
}
async fn run_multi_ip_scan(&self) -> Result<ScanResults> {
use crate::scanner::multi_ip::MultiIpScanner;
let scanner = MultiIpScanner::new(self.get_target_owned(), self.args.clone());
let report = scanner.scan_all_ips().await?;
let per_ip_results: Vec<_> = report
.per_ip_results
.values()
.map(|result| crate::utils::anycast::IpScanResult {
ip: result.ip,
results: result.scan_result.clone(),
error: result.error.clone(),
})
.collect();
let mut aggregated = self.build_conservative_multi_ip_result(&report)?;
aggregated.scanned_ips = per_ip_results;
aggregated.multi_ip_report = Some(report);
Ok(aggregated)
}
fn build_conservative_multi_ip_result(
&self,
report: &crate::scanner::multi_ip::MultiIpScanReport,
) -> Result<ScanResults> {
let base_scan = report
.per_ip_results
.values()
.find(|result| result.is_successful())
.ok_or_else(|| {
crate::TlsError::Other(format!("All {} IP address scans failed", report.total_ips))
})?;
let mut aggregated = base_scan.scan_result.clone();
aggregated.protocols = report.aggregated.protocols.clone();
aggregated.ciphers = report.aggregated.ciphers.clone();
aggregated.vulnerabilities = Self::aggregate_vulnerabilities(&report.per_ip_results);
aggregated.certificate_chain = Self::select_common_certificate_chain(
&report.per_ip_results,
report.aggregated.certificate_info.as_ref(),
);
aggregated.inconsistencies = Some(report.inconsistencies.clone());
let certificate_validation = aggregated
.certificate_chain
.as_ref()
.map(|cert| &cert.validation);
aggregated.rating = Some(RatingResults {
ssl_rating: Some(RatingCalculator::calculate(
&aggregated.protocols,
&aggregated.ciphers,
certificate_validation,
&aggregated.vulnerabilities,
)),
});
Ok(aggregated)
}
fn aggregate_vulnerabilities(
results: &std::collections::HashMap<
std::net::IpAddr,
crate::scanner::inconsistency::SingleIpScanResult,
>,
) -> Vec<VulnerabilityResult> {
let mut aggregated: Vec<VulnerabilityResult> = Vec::new();
for result in results.values() {
if result.error.is_some() {
continue;
}
for vuln in &result.scan_result.vulnerabilities {
let existing = aggregated
.iter_mut()
.find(|item| item.vuln_type == vuln.vuln_type);
match existing {
None => aggregated.push(vuln.clone()),
Some(item) => merge_vulnerability_result(item, vuln),
}
}
}
aggregated
}
fn select_common_certificate_chain(
results: &std::collections::HashMap<
std::net::IpAddr,
crate::scanner::inconsistency::SingleIpScanResult,
>,
certificate_info: Option<&crate::certificates::parser::CertificateInfo>,
) -> Option<CertificateAnalysisResult> {
let fingerprint = certificate_info.and_then(|cert| cert.fingerprint_sha256.as_ref());
let Some(fingerprint) = fingerprint else {
return results
.values()
.filter(|result| result.error.is_none())
.find_map(|result| result.scan_result.certificate_chain.clone());
};
results
.values()
.filter(|result| result.error.is_none())
.find_map(|result| {
let chain = result.scan_result.certificate_chain.as_ref()?;
let leaf = chain.chain.leaf()?;
if leaf.fingerprint_sha256.as_ref()? == fingerprint {
Some(chain.clone())
} else {
None
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CertificateAnalysisResult {
pub chain: CertificateChain,
pub validation: ValidationResult,
pub revocation: Option<RevocationResult>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FingerprintResults {
pub ja3_fingerprint: Option<crate::fingerprint::Ja3Fingerprint>,
pub ja3_match: Option<crate::fingerprint::Ja3Signature>,
pub ja3s_fingerprint: Option<crate::fingerprint::Ja3sFingerprint>,
pub ja3s_match: Option<crate::fingerprint::Ja3sSignature>,
pub jarm_fingerprint: Option<crate::fingerprint::JarmFingerprint>,
pub client_hello_raw: Option<Vec<u8>>,
pub server_hello_raw: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HttpResults {
pub http_headers: Option<HeaderAnalysisResult>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RatingResults {
pub ssl_rating: Option<RatingResult>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AdvancedResults {
pub intolerance: Option<crate::protocols::intolerance::IntoleranceTestResult>,
pub alpn_result: Option<crate::protocols::alpn::AlpnReport>,
pub signature_algorithms: Option<crate::protocols::signatures::SignatureEnumerationResult>,
pub key_exchange_groups: Option<crate::protocols::groups::GroupEnumerationResult>,
pub client_simulations: Option<Vec<ClientSimulationResult>>,
pub client_cas: Option<crate::protocols::client_cas::ClientCAsResult>,
pub cdn_detection: Option<crate::fingerprint::CdnDetection>,
pub load_balancer_info: Option<crate::fingerprint::LoadBalancerInfo>,
pub ct_log_source: Option<String>,
pub ct_log_index: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScanResults {
pub target: String,
pub scan_time_ms: u64,
pub protocols: Vec<ProtocolTestResult>,
pub ciphers: HashMap<Protocol, ProtocolCipherSummary>,
pub certificate_chain: Option<CertificateAnalysisResult>,
pub fingerprints: Option<FingerprintResults>,
pub http: Option<HttpResults>,
pub vulnerabilities: Vec<VulnerabilityResult>,
pub rating: Option<RatingResults>,
pub advanced: Option<AdvancedResults>,
pub pre_handshake_used: bool,
pub scanned_ips: Vec<crate::utils::anycast::IpScanResult>,
pub sni_used: Option<String>,
pub sni_generation_method: Option<SniMethod>,
pub probe_status: crate::output::probe_status::ProbeStatus,
pub inconsistencies: Option<Vec<crate::scanner::inconsistency::Inconsistency>>,
#[serde(skip)]
pub multi_ip_report: Option<crate::scanner::multi_ip::MultiIpScanReport>,
}
impl ScanResults {
pub fn http_headers(&self) -> Option<&HeaderAnalysisResult> {
self.http.as_ref().and_then(|h| h.http_headers.as_ref())
}
pub fn ssl_rating(&self) -> Option<&RatingResult> {
self.rating.as_ref().and_then(|r| r.ssl_rating.as_ref())
}
pub fn ja3_fingerprint(&self) -> Option<&crate::fingerprint::Ja3Fingerprint> {
self.fingerprints
.as_ref()
.and_then(|f| f.ja3_fingerprint.as_ref())
}
pub fn ja3_match(&self) -> Option<&crate::fingerprint::Ja3Signature> {
self.fingerprints
.as_ref()
.and_then(|f| f.ja3_match.as_ref())
}
pub fn ja3s_fingerprint(&self) -> Option<&crate::fingerprint::Ja3sFingerprint> {
self.fingerprints
.as_ref()
.and_then(|f| f.ja3s_fingerprint.as_ref())
}
pub fn ja3s_match(&self) -> Option<&crate::fingerprint::Ja3sSignature> {
self.fingerprints
.as_ref()
.and_then(|f| f.ja3s_match.as_ref())
}
pub fn jarm_fingerprint(&self) -> Option<&crate::fingerprint::JarmFingerprint> {
self.fingerprints
.as_ref()
.and_then(|f| f.jarm_fingerprint.as_ref())
}
pub fn client_simulations(&self) -> Option<&Vec<ClientSimulationResult>> {
self.advanced
.as_ref()
.and_then(|a| a.client_simulations.as_ref())
}
pub fn intolerance(&self) -> Option<&crate::protocols::intolerance::IntoleranceTestResult> {
self.advanced.as_ref().and_then(|a| a.intolerance.as_ref())
}
pub fn alpn_result(&self) -> Option<&crate::protocols::alpn::AlpnReport> {
self.advanced.as_ref().and_then(|a| a.alpn_result.as_ref())
}
pub fn signature_algorithms(
&self,
) -> Option<&crate::protocols::signatures::SignatureEnumerationResult> {
self.advanced
.as_ref()
.and_then(|a| a.signature_algorithms.as_ref())
}
pub fn key_exchange_groups(&self) -> Option<&crate::protocols::groups::GroupEnumerationResult> {
self.advanced
.as_ref()
.and_then(|a| a.key_exchange_groups.as_ref())
}
pub fn client_cas(&self) -> Option<&crate::protocols::client_cas::ClientCAsResult> {
self.advanced.as_ref().and_then(|a| a.client_cas.as_ref())
}
pub fn cdn_detection(&self) -> Option<&crate::fingerprint::CdnDetection> {
self.advanced
.as_ref()
.and_then(|a| a.cdn_detection.as_ref())
}
pub fn load_balancer_info(&self) -> Option<&crate::fingerprint::LoadBalancerInfo> {
self.advanced
.as_ref()
.and_then(|a| a.load_balancer_info.as_ref())
}
pub fn fingerprints_mut(&mut self) -> &mut FingerprintResults {
self.fingerprints
.get_or_insert_with(FingerprintResults::default)
}
pub fn http_mut(&mut self) -> &mut HttpResults {
self.http.get_or_insert_with(HttpResults::default)
}
pub fn rating_mut(&mut self) -> &mut RatingResults {
self.rating.get_or_insert_with(RatingResults::default)
}
pub fn advanced_mut(&mut self) -> &mut AdvancedResults {
self.advanced.get_or_insert_with(AdvancedResults::default)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SniMethod {
Hostname,
ReversePTR,
Random,
Custom(String),
}
impl ScanResults {
pub fn to_json(&self, pretty: bool) -> Result<String> {
if pretty {
Ok(serde_json::to_string_pretty(self)?)
} else {
Ok(serde_json::to_string(self)?)
}
}
pub fn to_csv(&self) -> Result<String> {
let mut csv = String::new();
csv.push_str("Type,Severity,Vulnerable,CVE,Details\n");
for vuln in &self.vulnerabilities {
csv.push_str(&format!(
"{:?},{:?},{},{},{}\n",
vuln.vuln_type,
vuln.severity,
vuln.vulnerable,
vuln.cve.as_deref().unwrap_or("N/A"),
vuln.details.replace(',', ";")
));
}
Ok(csv)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_results_json() {
let results = ScanResults {
target: "example.com:443".to_string(),
scan_time_ms: 1234,
..Default::default()
};
let json = results
.to_json(false)
.expect("test assertion should succeed");
assert!(json.contains("example.com"));
}
#[test]
fn test_scan_results_csv() {
let results = ScanResults::default();
let csv = results.to_csv().expect("test assertion should succeed");
assert!(csv.contains("Type,Severity"));
}
}