use super::config::SecurityPolicy;
use super::error::EngineError;
use super::model::Diagnostic;
pub fn analyze(source: &str, policy: &SecurityPolicy) -> Result<Vec<Diagnostic>, EngineError> {
let mut diagnostics = Vec::new();
if !policy.allow_dynamic_import && has_identifier_call(source, "import") {
return Err(EngineError::new(
"RWE_SECURITY_DYNAMIC_IMPORT",
"dynamic import() is blocked by security policy",
));
}
if !policy.allow_raw_html && source.contains("dangerouslySetInnerHTML") {
return Err(EngineError::new(
"RWE_SECURITY_RAW_HTML",
"dangerouslySetInnerHTML is blocked by security policy",
));
}
for blocked in &policy.blocked_globals {
if contains_blocked_global(source, blocked) {
return Err(EngineError::new(
"RWE_SECURITY_GLOBAL",
format!("blocked global found in source: {blocked}"),
));
}
}
check_fetch_domain_allowlist(source, policy)?;
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("<script") {
diagnostics.push(Diagnostic {
code: "RWE_INLINE_SCRIPT_WARN".to_string(),
message: "inline <script> tag in TSX source".to_string(),
line: Some((idx + 1) as u32),
});
}
}
Ok(diagnostics)
}
fn check_fetch_domain_allowlist(source: &str, policy: &SecurityPolicy) -> Result<(), EngineError> {
if policy.network_allowlist.is_empty() {
return Ok(()); }
let bytes = source.as_bytes();
let pattern = b"fetch(";
let mut i = 0;
while i + pattern.len() <= bytes.len() {
if &bytes[i..i + pattern.len()] == pattern {
let mut j = i + pattern.len();
while j < bytes.len() && (bytes[j] as char).is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
let quote = bytes[j];
j += 1;
let url_start = j;
while j < bytes.len() && bytes[j] != quote && bytes[j] != b'\n' {
j += 1;
}
let url = std::str::from_utf8(&bytes[url_start..j]).unwrap_or("");
if url.starts_with("http://") || url.starts_with("https://") {
let domain = extract_fetch_domain(url);
let allowed = policy
.network_allowlist
.iter()
.any(|a| domain == a.as_str() || domain.ends_with(&format!(".{a}")));
if !allowed {
return Err(EngineError::new(
"RWE_SECURITY_FETCH",
format!("fetch() to '{domain}' is not in network_allowlist"),
));
}
}
}
}
i += 1;
}
Ok(())
}
fn extract_fetch_domain(url: &str) -> &str {
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let host = without_scheme.split('/').next().unwrap_or(without_scheme);
host.split(':').next().unwrap_or(host) }
fn contains_blocked_global(source: &str, blocked: &str) -> bool {
match blocked {
"eval" => has_identifier_call(source, "eval"),
"Function" => has_identifier_call(source, "Function") || source.contains("new Function"),
"globalThis.Function" => source.contains("globalThis.Function"),
other => source.contains(other),
}
}
fn has_identifier_call(source: &str, ident: &str) -> bool {
let bytes = source.as_bytes();
let ident_bytes = ident.as_bytes();
if ident_bytes.is_empty() || bytes.len() < ident_bytes.len() {
return false;
}
let mut i = 0usize;
while i + ident_bytes.len() <= bytes.len() {
if &bytes[i..i + ident_bytes.len()] == ident_bytes {
let before_ok = i == 0 || !is_ident_char(bytes[i - 1] as char);
if before_ok {
let mut j = i + ident_bytes.len();
let after_ident_ok = j >= bytes.len() || !is_ident_char(bytes[j] as char);
if after_ident_ok {
while j < bytes.len() && (bytes[j] as char).is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && bytes[j] == b'(' {
return true;
}
}
}
}
i += 1;
}
false
}
fn is_ident_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'
}