use std::collections::HashMap;
use std::io::Cursor;
use winreg_core::hive::Hive;
use winreg_core::key::filetime_to_datetime;
const TYPED_URLS_PATH: &str = "Software\\Microsoft\\Internet Explorer\\TypedURLs";
const TYPED_URLS_TIME_PATH: &str = "Software\\Microsoft\\Internet Explorer\\TypedURLsTime";
const SUSPICIOUS_DOMAINS: &[&str] = &[
"pastebin.com",
"paste.ee",
"hastebin.com",
"transfer.sh",
"mega.nz",
"anonfiles.com",
"file.io",
"temp.sh",
"gofile.io",
"ngrok.io",
"trycloudflare.com",
];
#[derive(Debug, Clone, serde::Serialize)]
pub struct TypedUrl {
pub url: String,
pub last_visited: Option<String>,
pub is_suspicious: bool,
pub suspicious_reason: Option<String>,
}
pub fn classify_url(url: &str) -> Option<String> {
let lower = url.to_ascii_lowercase();
for &domain in SUSPICIOUS_DOMAINS {
if lower.contains(domain) {
return Some(format!("suspicious domain: {domain}"));
}
}
if let Some(scheme_end) = lower.find("://") {
let after_scheme = &lower[scheme_end + 3..];
let authority = match after_scheme.find('/') {
Some(pos) => &after_scheme[..pos],
None => after_scheme,
};
let host = match authority.rfind(':') {
Some(pos) => &authority[..pos],
None => authority,
};
if is_ipv4(host) {
return Some(format!("raw IP address in URL: {host}"));
}
}
None
}
fn is_ipv4(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 4 {
return false;
}
parts.iter().all(|p| p.parse::<u8>().is_ok())
}
pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<TypedUrl> {
let urls_key = match hive.open_key(TYPED_URLS_PATH) {
Ok(Some(k)) => k,
_ => return Vec::new(),
};
let time_map: HashMap<String, String> = match hive.open_key(TYPED_URLS_TIME_PATH) {
Ok(Some(time_key)) => {
let mut map = HashMap::new();
if let Ok(vals) = time_key.values() {
for val in vals {
if let Ok(raw) = val.raw_data() {
if raw.len() >= 8 {
let ft = winreg_core::bytes::le_u64(&raw[..], 0);
if let Some(dt) = filetime_to_datetime(ft) {
map.insert(val.name(), dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
}
}
}
}
}
map
}
_ => HashMap::new(),
};
let values = match urls_key.values() {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut entries = Vec::new();
for val in values {
let url = match val.as_string() {
Ok(s) if !s.is_empty() => s,
_ => continue,
};
let last_visited = time_map.get(&val.name()).cloned();
let suspicious_reason = classify_url(&url);
let is_suspicious = suspicious_reason.is_some();
entries.push(TypedUrl {
url,
last_visited,
is_suspicious,
suspicious_reason,
});
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_normal_url_is_none() {
assert!(classify_url("https://www.google.com").is_none());
assert!(classify_url("https://github.com/user/repo").is_none());
}
#[test]
fn classify_pastebin_suspicious() {
assert!(classify_url("https://pastebin.com/abc").is_some());
}
#[test]
fn classify_ngrok_suspicious() {
assert!(classify_url("https://abc.ngrok.io/shell").is_some());
}
#[test]
fn classify_raw_ip_suspicious() {
assert!(classify_url("http://192.168.1.100/payload").is_some());
assert!(classify_url("http://10.0.0.1/evil").is_some());
}
#[test]
fn classify_ip_with_port_suspicious() {
assert!(classify_url("http://192.168.1.1:8080/x").is_some());
}
#[test]
fn classify_normal_domain_with_dots_not_ip() {
assert!(classify_url("http://1.2.3.256/page").is_none());
}
}