use std::collections::BTreeSet;
use super::files::{self, Kind, is_comment};
use super::{Change, Hit, Level, NEW_HOST};
use crate::host;
const SCHEMES: &[&str] = &["http", "https", "ws", "wss", "ftp", "ftps", "sftp"];
const RESERVED_TLDS: &[&str] = &["example", "test", "invalid", "localhost", "local"];
const RESERVED_DOMAINS: &[&str] = &["example.com", "example.net", "example.org"];
const NAMESPACE_HOSTS: &[&str] = &[
"www.w3.org",
"w3.org",
"json-schema.org",
"schema.org",
"schemas.xmlsoap.org",
"schemas.microsoft.com",
"schemas.openxmlformats.org",
"schemas.android.com",
"purl.org",
"xmlns.com",
"ns.adobe.com",
];
pub fn new_hosts(changes: &[Change<'_>], allowed: impl Fn(&str) -> bool) -> Vec<(usize, Hit)> {
let removed: BTreeSet<String> = changes
.iter()
.flat_map(|c| c.blocks.iter().flat_map(|b| &b.removed))
.flat_map(|t| hosts_in(t))
.collect();
let mut seen = BTreeSet::new();
let mut found = Vec::new();
for (index, change) in changes.iter().enumerate() {
let file = files::classify(change.path);
if file.is(Kind::Doc) || file.test || file.lock.is_some() || file.manifest.is_some() {
continue;
}
for (line, text) in change.added {
if is_comment(text) {
continue;
}
for host in hosts_in(text) {
if removed.contains(&host) || !seen.insert(host.clone()) || allowed(&host) {
continue;
}
let reason = format!(
"adds a connection to {host}, which the policy's network allow list does not \
name: check what the code sends there"
);
found.push((index, Hit::new(NEW_HOST, Level::Review, *line, reason)));
}
}
}
found
}
fn hosts_in(text: &str) -> Vec<String> {
let lower = text.to_ascii_lowercase();
let mut hosts = Vec::new();
for (at, _) in lower.match_indices("://") {
let scheme_start = lower[..at]
.char_indices()
.rev()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
.map_or(0, |(i, c)| i + c.len_utf8());
let scheme = lower[scheme_start..at]
.rsplit('+')
.next()
.unwrap_or_default();
if !SCHEMES.contains(&scheme) {
continue;
}
let rest = &lower[at + 3..];
let authority = rest
.split(|c: char| {
!c.is_ascii()
|| c.is_whitespace()
|| matches!(
c,
'/' | '?' | '#' | '"' | '\'' | '`' | '<' | '>' | ')' | ',' | ';' | '\\'
)
})
.next()
.unwrap_or_default();
if authority.contains(['$', '{', '}', '%', '*', '[']) {
continue;
}
let Some(host) = host::of_url(authority) else {
continue;
};
let address = host.parse::<core::net::IpAddr>().is_ok();
if (address || host::fetchable(&host)) && endpoint(&host) && !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
fn endpoint(host: &str) -> bool {
let under = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
let tld = host.rsplit('.').next().unwrap_or(host);
!host::is_loopback(host)
&& !RESERVED_TLDS.contains(&tld)
&& !RESERVED_DOMAINS.iter().any(|d| under(d))
&& !NAMESPACE_HOSTS.contains(&host)
}