use secfinding::Severity;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct PortList {
pub list: String,
pub ports: Vec<u16>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RiskyService {
pub port: u16,
pub name: String,
#[serde(deserialize_with = "deserialize_severity")]
pub severity: Severity,
pub detail: String,
}
#[derive(Debug, Deserialize)]
struct PortListsFile {
ports: Vec<PortList>,
}
#[derive(Debug, Deserialize)]
struct RiskyServicesFile {
service: Vec<RiskyService>,
}
fn deserialize_severity<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Severity, D::Error> {
let s = String::deserialize(d)?;
match s.to_ascii_lowercase().as_str() {
"info" => Ok(Severity::Info),
"low" => Ok(Severity::Low),
"medium" => Ok(Severity::Medium),
"high" => Ok(Severity::High),
"critical" => Ok(Severity::Critical),
other => Err(serde::de::Error::custom(format!(
"unknown severity: {other}"
))),
}
}
const BUILTIN_TOP_PORTS: &str = include_str!("../rules/top_ports.toml");
const BUILTIN_RISKY_SERVICES: &str = include_str!("../rules/risky_services.toml");
static PORT_LISTS: OnceLock<HashMap<String, Vec<u16>>> = OnceLock::new();
static RISKY_SERVICES: OnceLock<Vec<RiskyService>> = OnceLock::new();
static RISKY_PORT_INDEX: OnceLock<HashMap<u16, usize>> = OnceLock::new();
fn parse_port_lists(content: &str) -> Result<Vec<PortList>, toml::de::Error> {
toml::from_str::<PortListsFile>(content).map(|f| f.ports)
}
fn parse_risky_services(content: &str) -> Result<Vec<RiskyService>, toml::de::Error> {
toml::from_str::<RiskyServicesFile>(content).map(|f| f.service)
}
fn builtin_port_lists() -> &'static HashMap<String, Vec<u16>> {
PORT_LISTS.get_or_init(|| {
let mut map = HashMap::new();
match parse_port_lists(BUILTIN_TOP_PORTS) {
Ok(lists) => {
for list in lists {
map.insert(list.list, list.ports);
}
}
Err(e) => {
tracing::error!(error = %e, "failed to parse built-in top_ports.toml");
}
}
map
})
}
fn builtin_risky_services() -> &'static Vec<RiskyService> {
RISKY_SERVICES.get_or_init(|| {
parse_risky_services(BUILTIN_RISKY_SERVICES)
.expect("compiled-in risky_services.toml must be valid")
})
}
pub fn default_ports() -> &'static [u16] {
builtin_port_lists()
.get("default")
.map(|v| v.as_slice())
.unwrap_or(crate::top_ports::DEFAULT_PORTS)
}
pub fn top_100() -> &'static [u16] {
builtin_port_lists()
.get("top_100")
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn top_1000() -> &'static [u16] {
builtin_port_lists()
.get("top_1000")
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn risky_services() -> &'static [RiskyService] {
builtin_risky_services()
}
pub fn risky_service_by_port(port: u16) -> Option<&'static RiskyService> {
let index = RISKY_PORT_INDEX.get_or_init(|| {
builtin_risky_services()
.iter()
.enumerate()
.map(|(i, svc)| (svc.port, i))
.collect()
});
index.get(&port).map(|&i| &builtin_risky_services()[i])
}
pub fn load_community_port_lists(dir: &std::path::Path) -> HashMap<String, Vec<u16>> {
let mut lists = HashMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return lists, };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if filename == "top_ports" || filename == "risky_services" {
continue;
}
match std::fs::read_to_string(&path) {
Ok(content) => match parse_port_lists(&content) {
Ok(file_lists) => {
let count = file_lists.len();
for list in file_lists {
lists.insert(list.list, list.ports);
}
tracing::info!(path = %path.display(), lists = count, "loaded community port lists");
}
Err(e) => {
tracing::warn!(path = %path.display(), err = %e, "skipping malformed port lists file")
}
},
Err(e) => {
tracing::warn!(path = %path.display(), err = %e, "failed to read port lists file")
}
}
}
lists
}
pub fn load_community_risky_services(dir: &std::path::Path) -> Vec<RiskyService> {
let mut services = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return services, };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if filename == "top_ports" || filename == "risky_services" {
continue;
}
match std::fs::read_to_string(&path) {
Ok(content) => match parse_risky_services(&content) {
Ok(file_services) => {
let count = file_services.len();
services.extend(file_services);
tracing::info!(path = %path.display(), services = count, "loaded community risky services");
}
Err(e) => {
tracing::warn!(path = %path.display(), err = %e, "skipping malformed risky services file")
}
},
Err(e) => {
tracing::warn!(path = %path.display(), err = %e, "failed to read risky services file")
}
}
}
services
}
pub fn all_port_lists(community_dir: Option<&std::path::Path>) -> HashMap<String, Vec<u16>> {
let mut lists: HashMap<String, Vec<u16>> = builtin_port_lists().clone();
if let Some(dir) = community_dir {
let community = load_community_port_lists(dir);
lists.extend(community);
}
lists
}
pub fn all_risky_services(community_dir: Option<&std::path::Path>) -> Vec<RiskyService> {
let mut services = builtin_risky_services().clone();
if let Some(dir) = community_dir {
let community = load_community_risky_services(dir);
services.extend(community);
}
services
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_default_ports_are_nonempty() {
let ports = default_ports();
assert!(!ports.is_empty(), "default ports should not be empty");
assert!(ports.contains(&80), "default should include port 80");
assert!(ports.contains(&443), "default should include port 443");
assert!(ports.contains(&22), "default should include SSH");
}
#[test]
fn builtin_top_100_has_100_ports() {
let ports = top_100();
assert_eq!(ports.len(), 100, "top_100 should have exactly 100 ports");
}
#[test]
fn builtin_top_1000_has_approx_1000_ports() {
let ports = top_1000();
assert!(
ports.len() >= 950,
"top_1000 should have approximately 1000 ports (got {})",
ports.len()
);
}
#[test]
fn builtin_risky_services_are_nonempty() {
let services = risky_services();
assert!(!services.is_empty(), "risky services should not be empty");
assert!(
services.iter().any(|s| s.port == 2375),
"should include Docker port"
);
assert!(
services.iter().any(|s| s.port == 6379),
"should include Redis port"
);
}
#[test]
fn risky_service_by_port_returns_known_port() {
let redis = risky_service_by_port(6379);
assert!(
redis.is_some(),
"port 6379 should be in the risky-service index"
);
assert_eq!(redis.unwrap().port, 6379);
let docker = risky_service_by_port(2375);
assert!(
docker.is_some(),
"port 2375 should be in the risky-service index"
);
assert_eq!(docker.unwrap().port, 2375);
}
#[test]
fn risky_service_by_port_returns_none_for_unknown_port() {
assert!(risky_service_by_port(1).is_none());
assert!(risky_service_by_port(65535).is_none());
}
#[test]
fn risky_service_by_port_agrees_with_linear_scan() {
for svc in risky_services() {
let by_index = risky_service_by_port(svc.port);
assert!(
by_index.is_some(),
"port {} present in risky_services() but missing from index",
svc.port
);
assert_eq!(
by_index.unwrap().port,
svc.port,
"index returned wrong service for port {}",
svc.port
);
}
}
#[test]
fn risky_service_by_port_boundary_port_zero() {
assert!(risky_service_by_port(0).is_none());
}
#[test]
fn builtin_risky_services_is_non_empty_and_loaded() {
let services = risky_services();
assert!(
!services.is_empty(),
"built-in risky services must load successfully and not be empty"
);
assert!(services.iter().any(|s| s.port == 21 || s.port == 23 || s.port == 3389));
}
#[test]
fn risky_services_have_required_fields() {
for svc in risky_services() {
assert!(!svc.name.is_empty(), "service name should not be empty");
assert!(!svc.detail.is_empty(), "service detail should not be empty");
assert!(svc.port > 0, "port should be > 0");
}
}
#[test]
fn community_port_lists_load_from_toml() {
let dir = std::env::temp_dir().join("gossan_port_lists_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("custom.toml"),
r#"
[[ports]]
list = "custom"
ports = [8080, 8443, 3000]
"#,
)
.unwrap();
let lists = load_community_port_lists(&dir);
assert!(lists.contains_key("custom"));
assert_eq!(lists["custom"], vec![8080, 8443, 3000]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn community_risky_services_load_from_toml() {
let dir = std::env::temp_dir().join("gossan_risky_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("custom.toml"),
r#"
[[service]]
port = 1337
name = "Custom admin panel"
severity = "high"
detail = "Exposed administrative interface."
"#,
)
.unwrap();
let services = load_community_risky_services(&dir);
assert_eq!(services.len(), 1);
assert_eq!(services[0].port, 1337);
assert_eq!(services[0].name, "Custom admin panel");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn all_port_lists_includes_community() {
let dir = std::env::temp_dir().join("gossan_all_ports_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("extra.toml"),
r#"
[[ports]]
list = "extra"
ports = [1111, 2222]
"#,
)
.unwrap();
let lists = all_port_lists(Some(&dir));
assert!(lists.contains_key("default"), "should have default");
assert!(lists.contains_key("extra"), "should have extra");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn all_risky_services_includes_community() {
let builtin_count = risky_services().len();
let dir = std::env::temp_dir().join("gossan_all_risky_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(
dir.join("extra.toml"),
r#"
[[service]]
port = 9999
name = "Test service"
severity = "medium"
detail = "Test detail."
"#,
)
.unwrap();
let services = all_risky_services(Some(&dir));
assert_eq!(services.len(), builtin_count + 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn malformed_community_file_is_skipped() {
let dir = std::env::temp_dir().join("gossan_bad_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("broken.toml"), "this is not valid [[ports]]").unwrap();
let lists = load_community_port_lists(&dir);
assert!(
lists.is_empty(),
"malformed file should be skipped gracefully"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_directory_returns_empty() {
let dir = std::path::Path::new("/nonexistent/path/that/does/not/exist");
let lists = load_community_port_lists(dir);
assert!(lists.is_empty());
let services = load_community_risky_services(dir);
assert!(services.is_empty());
}
#[test]
fn parse_port_lists_works() {
let toml = r#"
[[ports]]
list = "test"
ports = [80, 443]
"#;
let lists = parse_port_lists(toml).unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0].list, "test");
assert_eq!(lists[0].ports, vec![80, 443]);
}
#[test]
fn parse_risky_services_works() {
let toml = r#"
[[service]]
port = 8080
name = "Test"
severity = "high"
detail = "Test detail."
"#;
let services = parse_risky_services(toml).unwrap();
assert_eq!(services.len(), 1);
assert_eq!(services[0].port, 8080);
assert_eq!(services[0].severity, Severity::High);
}
#[test]
fn parse_port_lists_empty_string_is_error() {
assert!(parse_port_lists("").is_err());
}
#[test]
fn parse_risky_services_empty_string_is_error() {
assert!(parse_risky_services("").is_err());
}
#[test]
fn parse_port_lists_malformed_does_not_panic() {
let bad = "[[ports]]\nlist = \"x\"\nports = [not_a_number]\n";
let _ = parse_port_lists(bad);
let bad2 = "this is not toml at all";
let _ = parse_port_lists(bad2);
}
#[test]
fn parse_risky_services_malformed_does_not_panic() {
let bad = "[[service]]\nport = \"not_a_number\"\n";
let _ = parse_risky_services(bad);
let bad2 = "random garbage";
let _ = parse_risky_services(bad2);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn parse_port_lists_never_panics(input in "[ -~]{0,200}") {
let _ = parse_port_lists(&input);
}
#[test]
fn parse_risky_services_never_panics(input in "[ -~]{0,200}") {
let _ = parse_risky_services(&input);
}
}
}