pub mod cve;
pub mod jarm;
pub mod tls;
pub mod top_ports;
use std::fmt;
use async_trait::async_trait;
use futures::StreamExt;
use gossan_core::{
Config, DiscoverySource, DomainTarget, HostTarget, PortMode, Protocol, ScanInput, ScanOutput,
Scanner, ServiceTarget, Target,
};
use secfinding::{Evidence, Finding, FindingBuilder, Severity};
use std::time::Duration;
use tokio::io::AsyncReadExt;
const PORTS: &[u16] = &[
21, 22, 23, 25, 53, 80, 110, 143, 389, 443, 445, 465, 587, 636, 993, 995, 1433, 1521, 2181,
2375, 2376, 3000, 3306, 3389, 4369, 4443, 5432, 5601, 5900, 5984, 6379, 7001, 7474, 8000, 8080,
8086, 8443, 8545, 8546, 8888, 9000, 9090, 9092, 9200, 9300, 10250, 11211, 27017, 27018, 30303,
50070,
];
struct RiskyService {
port: u16,
name: &'static str,
severity: Severity,
detail: &'static str,
}
const RISKY: &[RiskyService] = &[
RiskyService { port: 2375, name: "Docker daemon (no TLS)", severity: Severity::Critical,
detail: "Docker API exposed without TLS — full container control and host escape possible." },
RiskyService { port: 2376, name: "Docker daemon (TLS)", severity: Severity::High,
detail: "Docker TLS API exposed — verify client certificate requirements." },
RiskyService { port: 6379, name: "Redis exposed", severity: Severity::Critical,
detail: "Redis port exposed — often unauthenticated, allows data exfiltration and RCE via cron/SSH key write." },
RiskyService { port: 9200, name: "Elasticsearch HTTP API", severity: Severity::High,
detail: "Elasticsearch REST API exposed — may allow unauthenticated data access." },
RiskyService { port: 9300, name: "Elasticsearch Transport", severity: Severity::High,
detail: "Elasticsearch transport port exposed — cluster join attacks possible." },
RiskyService { port: 27017, name: "MongoDB exposed", severity: Severity::Critical,
detail: "MongoDB port exposed — often unauthenticated, full database access." },
RiskyService { port: 5984, name: "CouchDB HTTP API", severity: Severity::High,
detail: "CouchDB HTTP API exposed — check for Futon/Fauxton admin interface." },
RiskyService { port: 10250, name: "Kubernetes kubelet API", severity: Severity::Critical,
detail: "Kubernetes kubelet API exposed — allows exec into pods and node metadata exfiltration." },
RiskyService { port: 4369, name: "Erlang EPMD exposed", severity: Severity::High,
detail: "Erlang EPMD exposed — enables RabbitMQ/Erlang cluster attacks." },
RiskyService { port: 2181, name: "ZooKeeper exposed", severity: Severity::High,
detail: "ZooKeeper port exposed — Kafka cluster metadata accessible." },
RiskyService { port: 9092, name: "Kafka broker exposed", severity: Severity::High,
detail: "Kafka broker port exposed — may allow unauthenticated message consumption/production." },
RiskyService { port: 11211, name: "Memcached exposed", severity: Severity::Critical,
detail: "Memcached exposed — plaintext key-value access, DDoS amplification vector." },
RiskyService { port: 50070, name: "Hadoop NameNode HTTP", severity: Severity::High,
detail: "Hadoop NameNode web UI exposed — filesystem metadata and HDFS access." },
RiskyService { port: 8086, name: "InfluxDB HTTP API", severity: Severity::High,
detail: "InfluxDB API exposed — check for unauthenticated time-series data access." },
RiskyService { port: 5601, name: "Kibana exposed", severity: Severity::Medium,
detail: "Kibana UI exposed — may allow Elasticsearch access and Console RCE." },
RiskyService { port: 7474, name: "Neo4j Browser exposed", severity: Severity::Medium,
detail: "Neo4j browser/HTTP API exposed — check authentication requirements." },
RiskyService { port: 23, name: "Telnet service", severity: Severity::Critical,
detail: "Telnet service exposed — plaintext protocol, immediate compromise risk." },
RiskyService { port: 8545, name: "Ethereum JSON-RPC exposed", severity: Severity::Critical,
detail: "Ethereum node JSON-RPC (geth/besu) exposed — unauthenticated access allows reading wallet balances, sending transactions, and draining funds. eth_sendTransaction with unlocked accounts = theft." },
RiskyService { port: 8546, name: "Ethereum WebSocket RPC exposed", severity: Severity::Critical,
detail: "Ethereum node WebSocket RPC exposed — same risk as HTTP JSON-RPC: wallet drain, transaction injection." },
RiskyService { port: 30303, name: "Ethereum P2P devp2p exposed", severity: Severity::Medium,
detail: "Ethereum P2P port exposed — allows peer discovery, chain sync analysis, and eclipse attacks on the node." },
];
pub struct PortScanner;
impl Default for PortScanner {
fn default() -> Self {
Self::new()
}
}
impl PortScanner {
pub fn new() -> Self {
Self
}
}
impl fmt::Display for PortScanner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PortScanner({})", self.name())
}
}
pub fn finding_builder(
target: &Target,
severity: Severity,
title: impl Into<String>,
detail: impl Into<String>,
) -> FindingBuilder {
Finding::builder("portscan", target.domain().unwrap_or("?"), severity)
.title(title)
.detail(detail)
}
#[async_trait]
impl Scanner for PortScanner {
fn name(&self) -> &'static str {
"portscan"
}
fn tags(&self) -> &[&'static str] {
&["active", "network"]
}
fn accepts(&self, target: &Target) -> bool {
matches!(target, Target::Domain(_) | Target::Host(_))
}
async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<ScanOutput> {
let mut out = ScanOutput::empty();
let timeout = config.timeout();
let custom_buf: Vec<u16>;
let active_ports: &[u16] = match &config.port_mode {
PortMode::Default => PORTS,
PortMode::Top100 => top_ports::TOP_100,
PortMode::Top1000 => top_ports::TOP_1000,
PortMode::Full => {
custom_buf = (1u16..=65535).collect();
&custom_buf
}
PortMode::Custom(ports) => {
custom_buf = ports.clone();
&custom_buf
}
};
let pairs: Vec<(String, Option<String>, u16)> = input
.targets
.iter()
.filter(|t| self.accepts(t))
.flat_map(|t| {
let (addr, domain) = match t {
Target::Domain(d) => (d.domain.clone(), Some(d.domain.clone())),
Target::Host(h) => (h.ip.to_string(), h.domain.clone()),
_ => unreachable!(),
};
active_ports
.iter()
.map(move |&p| (addr.clone(), domain.clone(), p))
})
.collect();
let results: Vec<Option<(ServiceTarget, Vec<Finding>, Vec<Target>)>> = futures::stream::iter(pairs)
.map(|(addr, domain, port)| {
let proxy_opt = config.proxy.clone();
async move {
probe_port(&addr, domain, port, timeout, proxy_opt.as_deref()).await
}
})
.buffer_unordered(config.concurrency)
.collect()
.await;
for item in results.into_iter().flatten() {
let (svc, findings, extra_targets) = item;
tracing::debug!(host = ?svc.host.ip, port = svc.port, "open port");
out.findings.extend(findings);
out.targets.push(Target::Service(svc));
out.targets.extend(extra_targets);
}
tracing::info!(open = out.targets.len(), "port scan complete");
Ok(out)
}
}
async fn probe_port(
addr: &str,
domain: Option<String>,
port: u16,
timeout: Duration,
proxy: Option<&str>,
) -> Option<(ServiceTarget, Vec<Finding>, Vec<Target>)> {
let stream = tokio::time::timeout(timeout, gossan_core::net::connect_tcp(addr, port, proxy))
.await
.ok()?
.ok()?;
let ip = stream.peer_addr().ok()?.ip();
let banner = grab_banner(stream, timeout).await;
let tls = port == 443 || port == 8443 || port == 465 || port == 993 || port == 636;
let svc = ServiceTarget {
host: HostTarget {
ip,
domain: domain.clone(),
},
port,
protocol: Protocol::Tcp,
banner: banner.clone(),
tls,
};
let mut findings: Vec<Finding> = Vec::new();
let mut extra_targets: Vec<Target> = Vec::new();
if let Some(r) = RISKY.iter().find(|r| r.port == port) {
let target = Target::Service(svc.clone());
let mut f = finding_builder(&target, r.severity, r.name, r.detail)
.tag("exposure")
.tag("network");
if let Some(b) = &banner {
f = f.evidence(Evidence::Banner { raw: b.clone() });
}
findings.push(f.build().expect("finding builder: required fields are set"));
}
if let Some(ref b) = banner {
if let Some(id_finding) = identify_banner(b, &svc, port) {
let existing_max = findings.iter().map(|f| f.severity).max();
if existing_max.is_none_or(|max| id_finding.severity >= max) {
findings.clear();
findings.push(id_finding);
} else {
findings.push(id_finding);
}
}
}
if tls {
if let Some(cert) = tls::probe_tls(addr, port, timeout, proxy).await {
let days = tls::days_until_expiry(cert.not_after_unix);
let target = Target::Service(svc.clone());
if days < 0 {
findings.push(
finding_builder(&target, Severity::Critical,
format!("TLS certificate expired {} days ago", -days),
format!("Certificate for port {} expired. Browsers will show security warnings; service may be partially broken.", port))
.tag("tls").tag("cert").tag("expired")
.build().expect("finding builder: required fields are set"),
);
} else if days <= 14 {
findings.push(
finding_builder(&target, Severity::High,
format!("TLS certificate expires in {} days", days),
format!("Certificate for port {} expires very soon. Immediate renewal required.", port))
.tag("tls").tag("cert").tag("expiry")
.build().expect("finding builder: required fields are set"),
);
} else if days <= 30 {
findings.push(
finding_builder(
&target,
Severity::Medium,
format!("TLS certificate expires in {} days", days),
format!("Certificate for port {} expiring within 30 days.", port),
)
.tag("tls")
.tag("cert")
.tag("expiry")
.build()
.expect("finding builder: required fields are set"),
);
}
if cert.is_self_signed {
findings.push(
finding_builder(&target, Severity::Medium,
"Self-signed TLS certificate",
format!("Port {} uses a self-signed certificate — clients cannot verify authenticity. Possible MITM target.", port))
.tag("tls").tag("cert").tag("self-signed")
.build().expect("finding builder: required fields are set"),
);
}
let parent_domain = domain.as_deref().unwrap_or(addr);
for san in &cert.sans {
if san != parent_domain
&& (san.ends_with(&format!(".{}", parent_domain))
|| parent_domain.ends_with(&format!(".{}", san)))
{
extra_targets.push(Target::Domain(DomainTarget {
domain: san.clone(),
source: DiscoverySource::CertificateTransparency,
}));
}
}
tracing::debug!(port, subject = %cert.subject, issuer = %cert.issuer, sans = ?cert.sans, "TLS cert inspected");
}
{
let legacy = tls::probe_legacy(addr, port, timeout, proxy).await;
let target = Target::Service(svc.clone());
if legacy.supports_tls10 {
findings.push(
finding_builder(&target, Severity::High,
format!("TLS 1.0 supported on port {} — BEAST/POODLE vulnerable", port),
format!("Port {} accepts TLS 1.0 connections. TLS 1.0 has known protocol-level \
vulnerabilities (BEAST, POODLE) and was deprecated by RFC 8996. \
Modern clients still negotiate 1.0 as fallback — disable it.", port))
.tag("tls").tag("legacy-tls").tag("protocol")
.build().expect("finding builder: required fields are set")
);
}
if legacy.supports_tls11 {
findings.push(
finding_builder(&target, Severity::Medium,
format!("TLS 1.1 supported on port {} — deprecated (RFC 8996)", port),
format!("Port {} accepts TLS 1.1 connections. TLS 1.1 was deprecated alongside \
TLS 1.0 in RFC 8996 (March 2021). Configure the server to require \
TLS 1.2 or higher.", port))
.tag("tls").tag("legacy-tls").tag("protocol")
.build().expect("finding builder: required fields are set")
);
}
}
if let Some(fp) = jarm::fingerprint(addr, port, timeout, proxy).await {
let target = Target::Service(svc.clone());
let known_tag = jarm::identify(&fp);
let (severity, title, detail) = if let Some(name) = known_tag {
(Severity::Critical,
format!("JARM fingerprint matches {}", name),
format!("TLS fingerprint {} matches known C2/malware framework: {}. Verify — this host may be part of a threat actor's infrastructure.", fp, name))
} else {
(
Severity::Info,
"JARM TLS fingerprint".to_string(),
format!("JARM fingerprint: {} (Shodan: ssl.jarm:{})", fp, fp),
)
};
findings.push(
finding_builder(&target, severity, title, detail)
.tag("jarm")
.tag("tls")
.tag("fingerprint")
.build()
.expect("finding builder: required fields are set"),
);
}
}
if let Some(ref b) = banner {
findings.extend(cve::correlate(b, &svc));
}
Some((svc, findings, extra_targets))
}
fn identify_banner(banner: &str, svc: &ServiceTarget, port: u16) -> Option<Finding> {
let b = banner.to_lowercase();
if b.starts_with("ssh-") || banner.starts_with("SSH-") {
let version = banner.lines().next().unwrap_or(banner).trim();
let severity = if version.contains("OpenSSH_7")
|| version.contains("OpenSSH_6")
|| version.contains("OpenSSH_5")
|| version.contains("OpenSSH_4")
{
Severity::High
} else {
Severity::Info
};
return Some(
finding_builder(
&Target::Service(svc.clone()),
severity,
format!("SSH version disclosed: {}", version),
"SSH banner reveals server version. Old versions may have known CVEs.",
)
.evidence(Evidence::Banner {
raw: banner.to_string(),
})
.tag("banner")
.tag("ssh")
.tag("version-disclosure")
.build()
.expect("finding builder: required fields are set"),
);
}
if (port == 21 || b.contains("ftp")) && (b.starts_with("220") || b.starts_with("230")) {
let version = banner.lines().next().unwrap_or(banner).trim();
return Some(
finding_builder(
&Target::Service(svc.clone()),
Severity::Info,
format!("FTP banner: {}", version),
"FTP banner may disclose server software and version.",
)
.evidence(Evidence::Banner {
raw: banner.to_string(),
})
.tag("banner")
.tag("ftp")
.tag("version-disclosure")
.build()
.expect("finding builder: required fields are set"),
);
}
if (port == 25 || port == 465 || port == 587) && b.starts_with("220") {
{
let version = banner.lines().next().unwrap_or(banner).trim();
return Some(
finding_builder(
&Target::Service(svc.clone()),
Severity::Info,
format!("SMTP banner: {}", version),
"SMTP banner may disclose mail server software and version.",
)
.evidence(Evidence::Banner {
raw: banner.to_string(),
})
.tag("banner")
.tag("smtp")
.tag("version-disclosure")
.build()
.expect("finding builder: required fields are set"),
);
}
}
if b.starts_with("http/") {
let server_line = banner
.lines()
.find(|l| l.to_lowercase().starts_with("server:"))
.unwrap_or("");
if !server_line.is_empty() {
return Some(
finding_builder(
&Target::Service(svc.clone()),
Severity::Info,
format!("HTTP server header: {}", server_line.trim()),
"HTTP Server header discloses software and version.",
)
.evidence(Evidence::Banner {
raw: banner.to_string(),
})
.tag("banner")
.tag("http")
.tag("version-disclosure")
.build()
.expect("finding builder: required fields are set"),
);
}
}
if port == 6379 && (b.starts_with("+") || b.starts_with("-")) {
return Some(
finding_builder(&Target::Service(svc.clone()), Severity::Critical,
"Redis responds without authentication",
"Redis accepted connection and responded — likely unauthenticated. Full data access and potential RCE via cron/SSH key write.")
.evidence(Evidence::Banner { raw: banner.to_string() })
.tag("banner").tag("redis").tag("no-auth")
.build().expect("finding builder: required fields are set"),
);
}
if port == 27017 && (banner.contains("MongoDB") || banner.contains("ismaster")) {
return Some(
finding_builder(
&Target::Service(svc.clone()),
Severity::Critical,
"MongoDB responds — likely unauthenticated",
"MongoDB accepted connection. May allow unauthenticated full database access.",
)
.evidence(Evidence::Banner {
raw: banner.to_string(),
})
.tag("banner")
.tag("mongodb")
.tag("no-auth")
.build()
.expect("finding builder: required fields are set"),
);
}
if port == 23 {
return Some(
finding_builder(&Target::Service(svc.clone()), Severity::Critical,
"Telnet service responds",
"Telnet is active and responding. All traffic is plaintext — immediate credential interception risk.")
.evidence(Evidence::Banner { raw: banner.to_string() })
.tag("banner").tag("telnet").tag("plaintext")
.build().expect("finding builder: required fields are set"),
);
}
None
}
pub async fn grab_banner(mut stream: tokio::net::TcpStream, _timeout: Duration) -> Option<String> {
let mut buf = vec![0u8; 512];
let n = match tokio::time::timeout(Duration::from_millis(800), stream.read(&mut buf)).await {
Ok(Ok(n)) => n,
Ok(Err(e)) => {
tracing::debug!(error = %e, "failed to read banner from stream");
return None;
}
Err(_) => {
tracing::debug!("banner grab timed out after 800ms");
return None;
}
};
if n == 0 {
tracing::debug!("banner read returned 0 bytes - connection closed immediately");
return None;
}
let s: String = buf[..n]
.iter()
.map(|&b| {
if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
}
})
.collect::<String>()
.trim()
.to_string();
if s.is_empty() {
tracing::debug!("banner contained only non-printable characters");
None
} else {
Some(s)
}
}
#[cfg(test)]
mod tests;