use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudMetadataExposureRequest {
pub domain: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudMetadataExposureResult {
pub domain: String,
pub imds_accessible: bool,
pub cloud_provider: Option<String>,
pub risk_level: String,
pub remediation: Option<String>,
}
pub async fn check_cloud_metadata_exposure(req: &CloudMetadataExposureRequest) -> Result<CloudMetadataExposureResult> {
let dns_req = crate::api::DnsCheckRequest {
domain: req.domain.clone(),
record_types: vec!["A".to_string()],
timeout_secs: req.timeout_secs,
..Default::default()
};
let mut imds_accessible = false;
let mut cloud_provider: Option<String> = None;
if let Ok(results) = crate::api::check_dns(&dns_req).await {
for result in &results {
for record in &result.answers {
if let crate::api::RecordData::A(ip_str) = &record.data {
if ip_str == "169.254.169.254" {
imds_accessible = true;
cloud_provider = Some("Cloud IMDS (AWS/GCP/Azure)".to_string());
}
if ip_str.starts_with("169.254.") {
imds_accessible = true;
}
}
}
}
}
let risk_level = if imds_accessible { "critical" } else { "low" }.to_string();
Ok(CloudMetadataExposureResult {
domain: req.domain.clone(),
imds_accessible,
cloud_provider,
risk_level,
remediation: if imds_accessible {
Some("Domain resolves to IMDS IP (169.254.x.x) — this indicates a critical DNS misconfiguration or SSRF risk".to_string())
} else {
None
},
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureBlobExposureRequest {
pub domain: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureBlobExposureResult {
pub domain: String,
pub has_azure_blob_cname: bool,
pub blob_account: Option<String>,
pub publicly_accessible: bool,
pub risk_level: String,
}
pub async fn check_azure_blob_exposure(req: &AzureBlobExposureRequest) -> Result<AzureBlobExposureResult> {
let dns_req = crate::api::DnsCheckRequest {
domain: req.domain.clone(),
record_types: vec!["CNAME".to_string()],
timeout_secs: req.timeout_secs,
..Default::default()
};
match crate::api::check_dns(&dns_req).await {
Ok(results) => {
for result in results {
for record in &result.answers {
if let crate::api::RecordData::Cname(cname) = &record.data {
if cname.contains("blob.core.windows.net") {
return Ok(AzureBlobExposureResult {
domain: req.domain.clone(),
has_azure_blob_cname: true,
blob_account: cname.split('.').next().map(|s| s.to_string()),
publicly_accessible: true,
risk_level: "high".to_string(),
});
}
}
}
}
Ok(AzureBlobExposureResult {
domain: req.domain.clone(),
has_azure_blob_cname: false,
blob_account: None,
publicly_accessible: false,
risk_level: "none".to_string(),
})
}
Err(_) => Ok(AzureBlobExposureResult {
domain: req.domain.clone(),
has_azure_blob_cname: false,
blob_account: None,
publicly_accessible: false,
risk_level: "unknown".to_string(),
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcsBucketExposureRequest {
pub domain: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcsBucketExposureResult {
pub domain: String,
pub has_gcs_cname: bool,
pub bucket_name: Option<String>,
pub publicly_accessible: bool,
pub risk_level: String,
}
pub async fn check_gcs_bucket_exposure(req: &GcsBucketExposureRequest) -> Result<GcsBucketExposureResult> {
let dns_req = crate::api::DnsCheckRequest {
domain: req.domain.clone(),
record_types: vec!["CNAME".to_string()],
timeout_secs: req.timeout_secs,
..Default::default()
};
match crate::api::check_dns(&dns_req).await {
Ok(results) => {
for result in results {
for record in &result.answers {
if let crate::api::RecordData::Cname(cname) = &record.data {
if cname.contains("storage.googleapis.com") {
return Ok(GcsBucketExposureResult {
domain: req.domain.clone(),
has_gcs_cname: true,
bucket_name: cname.split('.').next().map(|s| s.to_string()),
publicly_accessible: true,
risk_level: "high".to_string(),
});
}
}
}
}
Ok(GcsBucketExposureResult {
domain: req.domain.clone(),
has_gcs_cname: false,
bucket_name: None,
publicly_accessible: false,
risk_level: "none".to_string(),
})
}
Err(_) => Ok(GcsBucketExposureResult {
domain: req.domain.clone(),
has_gcs_cname: false,
bucket_name: None,
publicly_accessible: false,
risk_level: "unknown".to_string(),
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorsPolicyRequest {
pub url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorsPolicyResult {
pub url: String,
pub cors_enabled: bool,
pub allow_origin: Option<String>,
pub allow_methods: Option<String>,
pub allow_credentials: bool,
pub risk_level: String,
pub issues: Vec<String>,
}
pub async fn check_cors_policy(req: &CorsPolicyRequest) -> Result<CorsPolicyResult> {
crate::api::helpers::validate_url_safety(&req.url).map_err(crate::error::ShoheError::Parse)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(req.timeout_secs))
.build()
.map_err(|e| crate::error::ShoheError::Transport(e.to_string()))?;
match client.get(&req.url).send().await {
Ok(response) => {
let allow_origin = response
.headers()
.get("access-control-allow-origin")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let allow_methods = response
.headers()
.get("access-control-allow-methods")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let allow_credentials = response
.headers()
.get("access-control-allow-credentials")
.and_then(|h| h.to_str().ok())
.map(|s| s.contains("true"))
.unwrap_or(false);
let cors_enabled = allow_origin.is_some();
let mut issues = Vec::new();
let mut risk_level = "low".to_string();
if let Some(ref origin) = allow_origin {
if origin == "*" {
issues.push("Wildcard origin allows any domain".to_string());
risk_level = "medium".to_string();
if allow_credentials {
issues.push("Wildcard origin + credentials=true is a critical misconfiguration".to_string());
risk_level = "critical".to_string();
}
}
}
Ok(CorsPolicyResult {
url: req.url.clone(),
cors_enabled,
allow_origin,
allow_methods,
allow_credentials,
risk_level,
issues,
})
}
Err(_) => Ok(CorsPolicyResult {
url: req.url.clone(),
cors_enabled: false,
allow_origin: None,
allow_methods: None,
allow_credentials: false,
risk_level: "unknown".to_string(),
issues: vec!["Unable to fetch URL".to_string()],
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityTxtRequest {
pub domain: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityTxtResult {
pub domain: String,
pub file_found: bool,
pub contact: Option<String>,
pub policy: Option<String>,
pub encryption: Option<String>,
pub expires: Option<String>,
pub security_score: u8,
}
pub async fn check_security_txt(req: &SecurityTxtRequest) -> Result<SecurityTxtResult> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(req.timeout_secs))
.build()
.map_err(|e| crate::error::ShoheError::Transport(e.to_string()))?;
let url = format!("https://{}/.well-known/security.txt", req.domain);
match client.get(&url).send().await {
Ok(response) if response.status().is_success() => {
match response.text().await {
Ok(body) => {
let mut contact = None;
let mut policy = None;
let mut encryption = None;
let mut expires = None;
let mut score = 50u8;
for line in body.lines() {
if line.starts_with("Contact:") {
contact = line.split_once(':').map(|(_, v)| v.trim().to_string());
score = score.saturating_add(20);
} else if line.starts_with("Policy:") {
policy = line.split_once(':').map(|(_, v)| v.trim().to_string());
score = score.saturating_add(15);
} else if line.starts_with("Encryption:") {
encryption = line.split_once(':').map(|(_, v)| v.trim().to_string());
score = score.saturating_add(10);
} else if line.starts_with("Expires:") {
expires = line.split_once(':').map(|(_, v)| v.trim().to_string());
score = score.saturating_add(5);
}
}
Ok(SecurityTxtResult {
domain: req.domain.clone(),
file_found: true,
contact,
policy,
encryption,
expires,
security_score: std::cmp::min(100, score),
})
}
Err(_) => Ok(SecurityTxtResult {
domain: req.domain.clone(),
file_found: true,
contact: None,
policy: None,
encryption: None,
expires: None,
security_score: 0,
}),
}
}
_ => Ok(SecurityTxtResult {
domain: req.domain.clone(),
file_found: false,
contact: None,
policy: None,
encryption: None,
expires: None,
security_score: 0,
}),
}
}
fn default_timeout() -> u64 {
crate::api::helpers::DEFAULT_REQUEST_TIMEOUT_SECS
}