rastray 0.15.0

Blazing-fast static analysis CLI for security, dependency, and performance audits.
use std::fs;
use std::sync::OnceLock;

use regex::Regex;

use crate::cli::Severity;
use crate::crawler::{CrawlSummary, FileKind};
use crate::reporter::{Category, Finding, Location};

use super::{Analyzer, AnalyzerError};

#[derive(Debug, Default)]
pub struct NetworkAnalyzer;

impl NetworkAnalyzer {
    pub fn new() -> Self {
        Self
    }
}

impl Analyzer for NetworkAnalyzer {
    fn name(&self) -> &'static str {
        "network"
    }

    fn analyze(&self, crawl: &CrawlSummary) -> Result<Vec<Finding>, AnalyzerError> {
        let patterns = compiled_patterns()?;
        let mut findings = Vec::new();
        for file in &crawl.files {
            if file.kind != FileKind::Source {
                continue;
            }
            let Some(ext) = file
                .path
                .extension()
                .and_then(|s| s.to_str())
                .map(|s| s.to_ascii_lowercase())
            else {
                continue;
            };
            let contents = match fs::read_to_string(&file.path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            for pattern in patterns {
                if !pattern.extensions.iter().any(|e| *e == ext) {
                    continue;
                }
                for m in pattern.regex.find_iter(&contents) {
                    let (line, column) = byte_offset_to_line_col(&contents, m.start());
                    let location = Location::file(file.path.clone())
                        .with_span(m.start(), m.len())
                        .with_line(line, column);
                    findings.push(
                        Finding::new(
                            pattern.code,
                            pattern.message.to_string(),
                            pattern.severity,
                            Category::Security,
                        )
                        .with_help(pattern.help)
                        .with_location(location),
                    );
                }
            }
        }
        Ok(findings)
    }
}

struct PatternSpec {
    code: &'static str,
    message: &'static str,
    severity: Severity,
    help: &'static str,
    pattern: &'static str,
    extensions: &'static [&'static str],
}

struct CompiledPattern {
    code: &'static str,
    message: &'static str,
    severity: Severity,
    help: &'static str,
    regex: Regex,
    extensions: &'static [&'static str],
}

const JS_EXTENSIONS: &[&str] = &["js", "jsx", "ts", "tsx", "mjs", "cjs"];
const PY_EXTENSIONS: &[&str] = &["py"];
const GO_EXTENSIONS: &[&str] = &["go"];
const JAVA_EXTENSIONS: &[&str] = &["java", "kt", "kts"];

const PATTERN_SPECS: &[PatternSpec] = &[
    PatternSpec {
        code: "RSTR-NET-001",
        message: "TLS certificate verification disabled (verify=False); traffic is vulnerable to MITM",
        severity: Severity::High,
        help: "remove verify=False; if a custom CA is needed, pass its bundle path instead",
        pattern: r"\bverify\s*=\s*False\b",
        extensions: PY_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-001",
        message: "TLS certificate verification disabled (rejectUnauthorized: false); traffic is vulnerable to MITM",
        severity: Severity::High,
        help: "remove rejectUnauthorized: false; if a custom CA is needed, pass ca: [pem]",
        pattern: r"\brejectUnauthorized\s*:\s*false\b",
        extensions: JS_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-001",
        message: "TLS certificate verification disabled (InsecureSkipVerify: true); MITM vulnerable",
        severity: Severity::High,
        help: "remove InsecureSkipVerify; use a proper CA bundle (or RootCAs field) instead",
        pattern: r"\bInsecureSkipVerify\s*:\s*true\b",
        extensions: GO_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-002",
        message: "SSL context with disabled hostname checking; MITM vulnerable",
        severity: Severity::High,
        help: "do not set ctx.check_hostname = False; let it default to True",
        pattern: r"\.check_hostname\s*=\s*False\b",
        extensions: PY_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-002",
        message: "SSL context with CERT_NONE; certificate validation disabled",
        severity: Severity::High,
        help: "use ssl.CERT_REQUIRED with the system or a custom CA bundle",
        pattern: r"\bssl\.CERT_NONE\b|\bverify_mode\s*=\s*ssl\.CERT_NONE\b",
        extensions: PY_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-003",
        message: "Express CORS wildcard combined with credentials: true; browsers will reject and the configuration is a misconfiguration",
        severity: Severity::High,
        help: "list explicit origins; never combine '*' with credentials: true",
        pattern: r#"origin\s*:\s*['"]\*['"][^}]*credentials\s*:\s*true"#,
        extensions: JS_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-003",
        message: "Access-Control-Allow-Origin set to *; ensure no credentials are sent on this endpoint",
        severity: Severity::Medium,
        help: "list explicit allowed origins instead of '*' for any endpoint that may receive cookies or auth headers",
        pattern: r#"['"]Access-Control-Allow-Origin['"][^,)]*['"]\*['"]"#,
        extensions: JS_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-004",
        message: "cookie option httpOnly: false; cookie will be readable from client-side JavaScript",
        severity: Severity::Medium,
        help: "set httpOnly: true (or omit the option) so the cookie is not exposed to JS",
        pattern: r"\bhttpOnly\s*:\s*false\b",
        extensions: JS_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-005",
        message: "Java HostnameVerifier accepts every hostname (verify always returns true); MITM-friendly TLS",
        severity: Severity::High,
        help: "remove the custom verifier and trust the JDK default. If you must override, validate `hostname` against an allow-list and call the default verifier on success — never `return true` unconditionally",
        pattern: r"\bsetHostnameVerifier\s*\(\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*,\s*[A-Za-z_][A-Za-z0-9_]*\s*\)\s*->\s*true\b",
        extensions: JAVA_EXTENSIONS,
    },
    PatternSpec {
        code: "RSTR-NET-005",
        message: "Java HostnameVerifier inner class returns true for every hostname; MITM-friendly TLS",
        severity: Severity::High,
        help: "remove the verifier override and trust the JDK default. If overriding is genuinely required, validate `hostname` against an allow-list before returning true",
        pattern: r"new\s+HostnameVerifier\s*\(\s*\)\s*\{[^}]*verify\s*\([^)]*\)\s*\{[^}]*return\s+true\b",
        extensions: JAVA_EXTENSIONS,
    },
];

static PATTERNS: OnceLock<Result<Vec<CompiledPattern>, regex::Error>> = OnceLock::new();

fn compiled_patterns() -> Result<&'static [CompiledPattern], AnalyzerError> {
    let cached = PATTERNS.get_or_init(|| {
        PATTERN_SPECS
            .iter()
            .map(|spec| {
                Regex::new(spec.pattern).map(|regex| CompiledPattern {
                    code: spec.code,
                    message: spec.message,
                    severity: spec.severity,
                    help: spec.help,
                    regex,
                    extensions: spec.extensions,
                })
            })
            .collect::<Result<Vec<_>, _>>()
    });
    match cached {
        Ok(v) => Ok(v.as_slice()),
        Err(e) => Err(AnalyzerError::Failed {
            name: "network",
            message: format!("failed to compile a builtin network pattern: {e}"),
        }),
    }
}

fn byte_offset_to_line_col(text: &str, offset: usize) -> (usize, usize) {
    let mut line = 1usize;
    let mut col = 1usize;
    for (i, ch) in text.char_indices() {
        if i >= offset {
            break;
        }
        if ch == '\n' {
            line += 1;
            col = 1;
        } else {
            col += 1;
        }
    }
    (line, col)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compiled_patterns_compile_cleanly() {
        assert!(compiled_patterns().is_ok());
    }

    #[test]
    fn verify_false_python_matches() {
        let patterns = match compiled_patterns() {
            Ok(p) => p,
            Err(_) => return,
        };
        let re = patterns
            .iter()
            .find(|p| p.code == "RSTR-NET-001" && p.extensions.contains(&"py"))
            .map(|p| &p.regex);
        let Some(re) = re else { return };
        assert!(re.is_match("requests.get(url, verify=False)"));
        assert!(re.is_match("requests.post(u, verify = False)"));
        assert!(!re.is_match("requests.get(url, verify=True)"));
    }

    #[test]
    fn reject_unauthorized_false_js_matches() {
        let patterns = match compiled_patterns() {
            Ok(p) => p,
            Err(_) => return,
        };
        let re = patterns
            .iter()
            .find(|p| p.code == "RSTR-NET-001" && p.extensions.contains(&"js"))
            .map(|p| &p.regex);
        let Some(re) = re else { return };
        assert!(re.is_match("const agent = new https.Agent({ rejectUnauthorized: false })"));
        assert!(!re.is_match("{ rejectUnauthorized: true }"));
    }

    #[test]
    fn insecure_skip_verify_go_matches() {
        let patterns = match compiled_patterns() {
            Ok(p) => p,
            Err(_) => return,
        };
        let re = patterns
            .iter()
            .find(|p| p.code == "RSTR-NET-001" && p.extensions.contains(&"go"))
            .map(|p| &p.regex);
        let Some(re) = re else { return };
        assert!(re.is_match("tls.Config{InsecureSkipVerify: true}"));
        assert!(re.is_match("InsecureSkipVerify : true,"));
        assert!(!re.is_match("InsecureSkipVerify: false"));
    }

    #[test]
    fn cors_wildcard_with_credentials_matches() {
        let patterns = match compiled_patterns() {
            Ok(p) => p,
            Err(_) => return,
        };
        let re = patterns
            .iter()
            .find(|p| p.code == "RSTR-NET-003" && p.message.contains("wildcard with credentials"))
            .map(|p| &p.regex);
        let Some(re) = re else { return };
        assert!(re.is_match(r#"cors({ origin: "*", credentials: true })"#));
        assert!(!re.is_match(r#"cors({ origin: "https://app.example.com", credentials: true })"#));
    }

    #[test]
    fn java_hostname_verifier_lambda_returns_true_matches() {
        let patterns = match compiled_patterns() {
            Ok(p) => p,
            Err(_) => return,
        };
        let res: Vec<_> = patterns
            .iter()
            .filter(|p| p.code == "RSTR-NET-005")
            .map(|p| &p.regex)
            .collect();
        if res.is_empty() {
            return;
        }
        let any = |s: &str| res.iter().any(|r| r.is_match(s));
        assert!(any(
            "conn.setHostnameVerifier((hostname, session) -> true);"
        ));
        assert!(any(
            "conn.setHostnameVerifier(new HostnameVerifier() { public boolean verify(String h, SSLSession s) { return true; } });",
        ));
        assert!(!any(
            "conn.setHostnameVerifier((hostname, session) -> ALLOW_LIST.contains(hostname));",
        ));
    }
}