use llm_shield_core::{
async_trait, Entity, Error, Result, RiskFactor, ScanResult, Scanner, ScannerType, Severity,
Vault,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct URLReachabilityConfig {
pub enable_http_checks: bool,
pub timeout_seconds: u64,
pub follow_redirects: bool,
pub max_urls_to_check: usize,
pub fail_on_unreachable: bool,
}
impl Default for URLReachabilityConfig {
fn default() -> Self {
Self {
enable_http_checks: false, timeout_seconds: 5,
follow_redirects: true,
max_urls_to_check: 10,
fail_on_unreachable: true,
}
}
}
static URL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"https?://[^\s<>"]+"#).unwrap()
});
pub struct URLReachability {
config: URLReachabilityConfig,
}
impl URLReachability {
pub fn new(config: URLReachabilityConfig) -> Result<Self> {
if config.timeout_seconds == 0 {
return Err(Error::config("timeout_seconds must be greater than 0"));
}
if config.max_urls_to_check == 0 {
return Err(Error::config("max_urls_to_check must be greater than 0"));
}
Ok(Self { config })
}
pub fn default_config() -> Result<Self> {
Self::new(URLReachabilityConfig::default())
}
fn extract_urls(&self, text: &str) -> Vec<String> {
URL_PATTERN
.find_iter(text)
.take(self.config.max_urls_to_check)
.map(|m| m.as_str().to_string())
.collect()
}
fn is_wellformed_url(&self, url: &str) -> bool {
if !url.starts_with("http://") && !url.starts_with("https://") {
return false;
}
if url.contains(' ') {
return false;
}
url::Url::parse(url).is_ok()
}
async fn check_url_reachability(&self, url: &str) -> URLCheckResult {
if !self.config.enable_http_checks {
return URLCheckResult {
url: url.to_string(),
is_reachable: self.is_wellformed_url(url),
status_code: None,
error: if self.is_wellformed_url(url) {
None
} else {
Some("Malformed URL".to_string())
},
};
}
URLCheckResult {
url: url.to_string(),
is_reachable: self.is_wellformed_url(url),
status_code: None,
error: Some("HTTP checks not implemented in this build".to_string()),
}
}
pub async fn scan_output(
&self,
_prompt: &str,
output: &str,
_vault: &Vault,
) -> Result<ScanResult> {
let urls = self.extract_urls(output);
if urls.is_empty() {
return Ok(ScanResult::pass(output.to_string())
.with_metadata("urls_found", "0"));
}
let mut check_results = Vec::new();
for url in &urls {
let result = self.check_url_reachability(url).await;
check_results.push(result);
}
let unreachable_urls: Vec<_> = check_results
.iter()
.filter(|r| !r.is_reachable)
.collect();
let metadata = ScanResult::pass(output.to_string())
.with_metadata("urls_found", urls.len().to_string())
.with_metadata("urls_checked", check_results.len().to_string())
.with_metadata("unreachable_urls", unreachable_urls.len().to_string());
if unreachable_urls.is_empty() {
return Ok(metadata);
}
if self.config.fail_on_unreachable {
let entities: Vec<Entity> = unreachable_urls
.iter()
.map(|r| {
let mut meta = HashMap::new();
meta.insert("url".to_string(), r.url.clone());
if let Some(code) = r.status_code {
meta.insert("status_code".to_string(), code.to_string());
}
if let Some(err) = &r.error {
meta.insert("error".to_string(), err.clone());
}
Entity {
entity_type: "unreachable_url".to_string(),
text: r.url.clone(),
start: 0,
end: output.len(),
confidence: 0.9,
metadata: meta,
}
})
.collect();
let severity = if unreachable_urls.len() == urls.len() {
Severity::High } else if unreachable_urls.len() > urls.len() / 2 {
Severity::Medium } else {
Severity::Low
};
let description = format!(
"{} of {} URL(s) are unreachable",
unreachable_urls.len(),
urls.len()
);
let risk_factor = RiskFactor::new(
"unreachable_urls",
&description,
severity,
unreachable_urls.len() as f32 / urls.len() as f32,
);
let mut result = ScanResult::new(
output.to_string(),
false,
unreachable_urls.len() as f32 / urls.len() as f32,
)
.with_risk_factor(risk_factor)
.with_metadata("urls_found", urls.len())
.with_metadata("unreachable_urls", unreachable_urls.len());
for entity in entities {
result = result.with_entity(entity);
}
Ok(result)
} else {
Ok(metadata)
}
}
}
#[derive(Debug, Clone)]
struct URLCheckResult {
url: String,
is_reachable: bool,
status_code: Option<u16>,
error: Option<String>,
}
#[async_trait]
impl Scanner for URLReachability {
fn name(&self) -> &str {
"URLReachability"
}
async fn scan(&self, input: &str, vault: &Vault) -> Result<ScanResult> {
self.scan_output("", input, vault).await
}
fn scanner_type(&self) -> ScannerType {
ScannerType::Output
}
fn description(&self) -> &str {
"Validates that URLs in LLM responses are reachable"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_url_reachability_no_urls() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "This response has no URLs.";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.is_valid);
assert_eq!(result.metadata.get("urls_found").unwrap(), "0");
}
#[tokio::test]
async fn test_url_reachability_wellformed() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "Visit https://www.example.com for more info";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.is_valid);
assert_eq!(result.metadata.get("urls_found").unwrap(), "1");
}
#[tokio::test]
async fn test_url_reachability_malformed() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "Visit ht tp://bad url.com"; let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.metadata.contains_key("urls_found"));
}
#[tokio::test]
async fn test_url_reachability_multiple_urls() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "Check https://example.com and https://test.com";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.is_valid);
assert_eq!(result.metadata.get("urls_found").unwrap(), "2");
}
#[tokio::test]
async fn test_url_reachability_max_urls() {
let config = URLReachabilityConfig {
max_urls_to_check: 2,
..Default::default()
};
let scanner = URLReachability::new(config).unwrap();
let vault = Vault::new();
let response = "URLs: https://a.com https://b.com https://c.com https://d.com";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert_eq!(result.metadata.get("urls_checked").unwrap(), "2");
}
#[tokio::test]
async fn test_url_reachability_dont_fail() {
let config = URLReachabilityConfig {
fail_on_unreachable: false,
..Default::default()
};
let scanner = URLReachability::new(config).unwrap();
let vault = Vault::new();
let response = "Visit https://definitely-not-a-real-site-12345.com";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.is_valid);
}
#[tokio::test]
async fn test_url_reachability_http_and_https() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "HTTP: http://example.com HTTPS: https://example.com";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert_eq!(result.metadata.get("urls_found").unwrap(), "2");
}
#[tokio::test]
async fn test_url_reachability_invalid_config() {
let config = URLReachabilityConfig {
timeout_seconds: 0, ..Default::default()
};
let result = URLReachability::new(config);
assert!(result.is_err());
}
#[tokio::test]
async fn test_url_reachability_zero_max_urls() {
let config = URLReachabilityConfig {
max_urls_to_check: 0, ..Default::default()
};
let result = URLReachability::new(config);
assert!(result.is_err());
}
#[tokio::test]
async fn test_url_reachability_url_with_path() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "Visit https://example.com/path/to/page?param=value";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.is_valid);
assert_eq!(result.metadata.get("urls_found").unwrap(), "1");
}
#[tokio::test]
async fn test_url_reachability_url_with_port() {
let scanner = URLReachability::default_config().unwrap();
let vault = Vault::new();
let response = "Connect to https://example.com:8080";
let result = scanner.scan_output("", response, &vault).await.unwrap();
assert!(result.is_valid);
}
}