use crate::http_client::HttpResponse;
use regex::Regex;
use std::collections::HashMap;
use tracing::{debug, info};
#[derive(Debug, Clone, PartialEq)]
pub enum AppType {
SinglePageApp(SpaFramework),
StaticSite,
ServerRendered,
Api,
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SpaFramework {
Vue,
React,
Angular,
Svelte,
Next,
Nuxt,
Other,
}
#[derive(Debug, Clone)]
pub struct AppCharacteristics {
pub app_type: AppType,
pub is_spa: bool,
pub is_static: bool,
pub is_api: bool,
pub is_api_only: bool,
pub has_server_side_rendering: bool,
pub has_authentication: bool,
pub has_oauth: bool,
pub has_jwt: bool,
pub has_mfa: bool,
pub has_file_upload: bool,
pub uses_client_side_routing: bool,
pub framework_indicators: Vec<String>,
}
impl AppCharacteristics {
pub fn default() -> Self {
Self {
app_type: AppType::Unknown,
is_spa: false,
is_static: false,
is_api: false,
is_api_only: false,
has_server_side_rendering: false,
has_authentication: false,
has_oauth: false,
has_jwt: false,
has_mfa: false,
has_file_upload: false,
uses_client_side_routing: false,
framework_indicators: Vec::new(),
}
}
pub fn from_response(response: &HttpResponse, url: &str) -> Self {
let body = &response.body;
let body_lower = body.to_lowercase();
let headers = &response.headers;
let mut characteristics = Self::default();
let spa_framework = detect_spa_framework(body, headers);
if spa_framework.is_some() {
characteristics.is_spa = true;
characteristics.app_type = AppType::SinglePageApp(spa_framework.unwrap());
characteristics.uses_client_side_routing = true;
}
if is_static_site(body, headers, &body_lower) {
characteristics.is_static = true;
if !characteristics.is_spa {
characteristics.app_type = AppType::StaticSite;
}
}
if is_api_response(body, headers, url) {
characteristics.is_api = true;
characteristics.is_api_only = true;
characteristics.app_type = AppType::Api;
} else if url.contains("/api/")
|| url.contains("/graphql")
|| url.contains("/v1/")
|| url.contains("/v2/")
{
characteristics.is_api = true;
}
if has_server_side_rendering(body, &body_lower) {
characteristics.has_server_side_rendering = true;
if characteristics.app_type == AppType::Unknown {
characteristics.app_type = AppType::ServerRendered;
}
}
characteristics.has_authentication = has_real_authentication(body, headers, &body_lower);
characteristics.has_oauth = has_real_oauth(body, headers, &body_lower);
characteristics.has_jwt = has_real_jwt(body, headers, &body_lower);
characteristics.has_mfa = has_real_mfa(body, &body_lower);
characteristics.has_file_upload = has_file_upload(body, &body_lower);
characteristics.framework_indicators = detect_framework_indicators(body, headers);
debug!(
"[Detection] App characteristics: {:?}",
characteristics.app_type
);
characteristics
}
pub fn should_skip_injection_tests(&self) -> bool {
false }
pub fn should_skip_auth_tests(&self) -> bool {
!self.has_authentication
}
pub fn should_skip_oauth_tests(&self) -> bool {
!self.has_oauth
}
pub fn should_skip_jwt_tests(&self) -> bool {
!self.has_jwt
}
pub fn should_skip_mfa_tests(&self) -> bool {
!self.has_mfa && !self.has_authentication
}
}
fn detect_spa_framework(body: &str, _headers: &HashMap<String, String>) -> Option<SpaFramework> {
let body_lower = body.to_lowercase();
if (body.contains("data-v-") || body.contains("__NUXT__") || body.contains("Vue.component"))
&& (body.contains("app.js")
|| body.contains("chunk-vendors")
|| body.contains("vue-router"))
{
info!("[Detection] Vue.js SPA detected");
return Some(SpaFramework::Vue);
}
if (body.contains("data-reactroot")
|| body.contains("data-reactid")
|| body.contains("__REACT_")
|| body.contains("_next/static"))
&& (body.contains("react-dom")
|| body.contains("main.chunk.js")
|| body.contains("bundle.js"))
{
info!("[Detection] React SPA detected");
return Some(SpaFramework::React);
}
if (body.contains("ng-version") || body.contains("ng-app") || body_lower.contains("angular"))
&& (body.contains("main.js") || body.contains("polyfills") || body.contains("runtime.js"))
{
info!("[Detection] Angular SPA detected");
return Some(SpaFramework::Angular);
}
if body.contains("_next/static") || body.contains("__NEXT_DATA__") {
info!("[Detection] Next.js (React SSR) detected");
return Some(SpaFramework::Next);
}
if body.contains("__NUXT__") || body_lower.contains("nuxt.js") {
info!("[Detection] Nuxt.js (Vue SSR) detected");
return Some(SpaFramework::Nuxt);
}
if body_lower.contains("svelte")
&& (body.contains("build/bundle") || body.contains("global.css"))
{
info!("[Detection] Svelte SPA detected");
return Some(SpaFramework::Svelte);
}
let has_spa_shell = body.contains("<div id=\"app\"")
|| body.contains("<div id=\"root\"")
|| body.contains("<noscript>You need to enable JavaScript")
|| body.contains("This app requires JavaScript");
let has_js_bundle = body.contains("app.js")
|| body.contains("main.js")
|| body.contains("bundle.js")
|| body.contains("chunk");
if has_spa_shell && has_js_bundle {
info!("[Detection] Generic SPA detected");
return Some(SpaFramework::Other);
}
None
}
fn is_static_site(body: &str, headers: &HashMap<String, String>, body_lower: &str) -> bool {
let static_generators = [
"jekyll",
"hugo",
"gatsby",
"eleventy",
"hexo",
"gridsome",
"vuepress",
"docusaurus",
];
for generator in &static_generators {
if body_lower.contains(generator)
|| headers
.get("x-powered-by")
.map(|h| h.to_lowercase().contains(generator))
.unwrap_or(false)
{
return true;
}
}
if let Some(server) = headers.get("server") {
let server_lower = server.to_lowercase();
if server_lower.contains("github.com")
|| server_lower.contains("netlify")
|| server_lower.contains("vercel")
{
return true;
}
}
let no_forms = !body.contains("<form");
let no_csrf_tokens = !body.contains("csrf") && !body.contains("_token");
let no_session_cookies = !headers
.get("set-cookie")
.map(|c| c.contains("session") || c.contains("PHPSESSID") || c.contains("JSESSIONID"))
.unwrap_or(false);
no_forms && no_csrf_tokens && no_session_cookies && body.len() < 100_000
}
fn is_api_response(body: &str, headers: &HashMap<String, String>, url: &str) -> bool {
if let Some(content_type) = headers.get("content-type") {
let ct_lower = content_type.to_lowercase();
if ct_lower.contains("application/json")
|| ct_lower.contains("application/xml")
|| ct_lower.contains("text/xml")
{
return true;
}
}
let url_lower = url.to_lowercase();
let api_paths = ["/api/", "/graphql", "/rest/", "/v1/", "/v2/", "/v3/"];
for path in &api_paths {
if url_lower.contains(path) {
let body_trimmed = body.trim();
if (body_trimmed.starts_with('{') && body_trimmed.ends_with('}'))
|| (body_trimmed.starts_with('[') && body_trimmed.ends_with(']'))
|| (body_trimmed.starts_with('<') && body_trimmed.contains("<?xml"))
{
return true;
}
}
}
false
}
fn has_server_side_rendering(body: &str, body_lower: &str) -> bool {
body.contains("__INITIAL_STATE__") ||
body.contains("__PRELOADED_STATE__") ||
body.contains("window.__DATA__") ||
body.contains("__NEXT_DATA__") ||
body.contains("__NUXT__") ||
body_lower.contains("x-powered-by: php") ||
body.contains("<%") || body.contains("<?php") ||
body_lower.contains("handlebars") ||
body_lower.contains("mustache") ||
body_lower.contains("ejs")
}
fn has_real_authentication(
body: &str,
headers: &HashMap<String, String>,
body_lower: &str,
) -> bool {
if let Some(cookies) = headers.get("set-cookie") {
let cookie_lower = cookies.to_lowercase();
if cookie_lower.contains("session")
|| cookie_lower.contains("auth")
|| cookie_lower.contains("token")
|| cookie_lower.contains("phpsessid")
|| cookie_lower.contains("jsessionid")
{
info!("[Detection] Real authentication detected: session cookie");
return true;
}
}
if headers.contains_key("www-authenticate") || headers.contains_key("authorization") {
info!("[Detection] Real authentication detected: auth headers");
return true;
}
let has_login_form = (body_lower.contains("<form")
&& (body_lower.contains("login") || body_lower.contains("sign in")))
&& (body.contains("password") || body.contains("type=\"password\""));
let has_csrf =
body.contains("csrf") || body.contains("_token") || body.contains("authenticity_token");
if has_login_form && has_csrf {
info!("[Detection] Real authentication detected: login form with CSRF");
return true;
}
if body.contains("/api/auth")
|| body.contains("/auth/login")
|| body.contains("authentication") && body.contains("endpoint")
{
info!("[Detection] Real authentication detected: auth endpoints");
return true;
}
false
}
fn has_real_oauth(body: &str, _headers: &HashMap<String, String>, body_lower: &str) -> bool {
let oauth_providers = [
"accounts.google.com/o/oauth2",
"login.microsoftonline.com",
"github.com/login/oauth",
"facebook.com/v",
"oauth.twitter.com",
"appleid.apple.com/auth",
];
for provider in &oauth_providers {
if body.contains(provider) && (body.contains("href=") || body.contains("action=")) {
info!("[Detection] Real OAuth detected: provider {}", provider);
return true;
}
}
let has_oauth_endpoint = (body_lower.contains("/oauth/authorize")
|| body_lower.contains("/oauth2/authorize"))
&& (body.contains("client_id") || body.contains("response_type"));
if has_oauth_endpoint {
info!("[Detection] Real OAuth detected: oauth endpoint with params");
return true;
}
let oauth_libs = ["gapi.auth2", "MSAL.", "passport.authenticate"];
for lib in &oauth_libs {
if body.contains(lib) && !body_lower.contains("documentation") {
info!("[Detection] Real OAuth detected: {} library", lib);
return true;
}
}
false
}
fn has_real_jwt(body: &str, headers: &HashMap<String, String>, body_lower: &str) -> bool {
if let Some(auth) = headers.get("authorization") {
if auth.starts_with("Bearer ") && auth.len() > 50 {
info!("[Detection] Real JWT detected: Bearer token in header");
return true;
}
}
if let Some(cookies) = headers.get("set-cookie") {
if cookies.contains("eyJ") || cookies.matches('.').count() >= 2 {
info!("[Detection] Real JWT detected: JWT in cookie");
return true;
}
}
if (body.contains("localStorage.setItem(") || body.contains("sessionStorage.setItem("))
&& (body.contains("\"token\"")
|| body.contains("'token'")
|| body.contains("\"jwt\"")
|| body.contains("'jwt'"))
&& !body_lower.contains("example")
&& !body_lower.contains("documentation")
{
info!("[Detection] Real JWT detected: token storage in JS");
return true;
}
let jwt_libs = ["jsonwebtoken", "jose", "jwt-decode", "njwt"];
for lib in &jwt_libs {
if body.contains(lib) && body.contains("import") && !body_lower.contains("documentation") {
info!("[Detection] Real JWT detected: {} library", lib);
return true;
}
}
false
}
fn has_real_mfa(body: &str, body_lower: &str) -> bool {
let has_mfa_form = (body_lower.contains("verification code")
|| body_lower.contains("authenticator app")
|| body_lower.contains("totp"))
&& (body.contains("<form") || body.contains("<input"));
if has_mfa_form && !body_lower.contains("documentation") && !body_lower.contains("learn more") {
info!("[Detection] Real MFA detected: MFA form");
return true;
}
if (body.contains("otpauth://totp/") || body_lower.contains("qr code"))
&& body.contains("secret=")
{
info!("[Detection] Real MFA detected: TOTP enrollment");
return true;
}
let mfa_libs = ["speakeasy", "otplib", "authenticator", "qrcode"];
for lib in &mfa_libs {
if body.contains(lib) && body.contains("import") && !body_lower.contains("documentation") {
info!("[Detection] Real MFA detected: {} library", lib);
return true;
}
}
false
}
fn detect_framework_indicators(body: &str, headers: &HashMap<String, String>) -> Vec<String> {
let mut indicators = Vec::new();
if let Some(powered_by) = headers.get("x-powered-by") {
indicators.push(format!("X-Powered-By: {}", powered_by));
}
if let Some(server) = headers.get("server") {
indicators.push(format!("Server: {}", server));
}
let frameworks = [
("Vue.js", "data-v-"),
("React", "data-reactroot"),
("Angular", "ng-version"),
("Next.js", "_next/static"),
("Nuxt.js", "__NUXT__"),
("Django", "csrfmiddlewaretoken"),
("Rails", "authenticity_token"),
("Laravel", "laravel_session"),
("Express", "express"),
("WordPress", "wp-content"),
];
for (name, indicator) in &frameworks {
if body.contains(indicator) {
indicators.push(name.to_string());
}
}
indicators
}
pub fn is_spa_route(url: &str, base_response: &HttpResponse) -> bool {
let characteristics = AppCharacteristics::from_response(base_response, url);
characteristics.is_spa && characteristics.uses_client_side_routing
}
pub fn is_payload_reflected_dangerously(response: &HttpResponse, payload: &str) -> bool {
let body = &response.body;
if body.contains("<script src=") && body.len() > 100_000 {
debug!("[Detection] Skipping reflection check - likely framework bundle");
return false;
}
let dangerous_contexts = vec![
format!(">{}<", payload), format!(">{}</", payload), format!("=\"{}\"", payload), format!("='{}'", payload), format!("='{}'>", payload), format!("('{}')", payload), format!("(\"{}\")", payload), format!("href=\"{}\"", payload), format!("src=\"{}\"", payload), format!("action=\"{}\"", payload), ];
for context in &dangerous_contexts {
if body.contains(context) {
info!("[Detection] Dangerous reflection detected: {}", context);
return true;
}
}
let unescaped_patterns = vec![
format!("<script>{}", payload),
format!("{}</script>", payload),
format!("onerror={}", payload),
format!("onclick={}", payload),
format!("onload={}", payload),
format!("javascript:{}", payload),
];
for pattern in &unescaped_patterns {
if body.contains(pattern.as_str()) {
info!("[Detection] Unescaped execution context detected");
return true;
}
}
false
}
pub fn endpoint_exists(response: &HttpResponse, expected_status_codes: &[u16]) -> bool {
if response.status_code == 404 {
return false;
}
if !expected_status_codes.is_empty() && !expected_status_codes.contains(&response.status_code) {
return false;
}
let body = &response.body;
let is_spa_shell = (body.contains("<div id=\"app\"") || body.contains("<div id=\"root\""))
&& body.contains("app.js")
|| body.contains("main.js");
if is_spa_shell && response.status_code == 200 {
debug!("[Detection] Endpoint returns SPA shell - likely doesn't exist");
return false;
}
true
}
pub fn discover_api_endpoints(base_url: &str, html_body: &str) -> Vec<String> {
let mut endpoints = Vec::new();
let base_lower = base_url.to_lowercase();
let api_patterns = vec![
r#"["']https?://[^"']+/(?:api|graphql|v\d+)[^"']*["']"#,
r#"baseURL:\s*["']([^"']+)["']"#,
r#"API_URL\s*=\s*["']([^"']+)["']"#,
r#"GRAPHQL_ENDPOINT\s*=\s*["']([^"']+)["']"#,
r#"axios\.(?:get|post)\(["']([^"']+)["']"#,
r#"fetch\(["']([^"']+)["']"#,
];
for pattern in &api_patterns {
if let Ok(re) = Regex::new(pattern) {
for cap in re.captures_iter(html_body) {
if let Some(url_match) = cap.get(1).or_else(|| cap.get(0)) {
let url = url_match.as_str().trim_matches(|c| c == '"' || c == '\'');
let full_url = if url.starts_with("http") {
url.to_string()
} else if url.starts_with('/') {
if let Ok(parsed) = url::Url::parse(base_url) {
format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
url
)
} else {
continue;
}
} else {
continue;
};
if !endpoints.contains(&full_url) {
info!("[Discovery] Found API endpoint: {}", full_url);
endpoints.push(full_url);
}
}
}
}
}
if endpoints.is_empty() {
if !base_lower.contains("/api")
&& !base_lower.contains("/graphql")
&& !base_lower.contains("/v1")
{
endpoints.push(format!("{}/api", base_url.trim_end_matches('/')));
endpoints.push(format!("{}/graphql", base_url.trim_end_matches('/')));
} else {
endpoints.push(base_url.to_string());
}
}
endpoints
}
pub fn did_payload_have_effect(
baseline: &HttpResponse,
response: &HttpResponse,
payload: &str,
) -> bool {
if baseline.status_code != response.status_code {
return true;
}
let baseline_len = baseline.body.len();
let response_len = response.body.len();
let len_diff = if baseline_len > response_len {
baseline_len - response_len
} else {
response_len - baseline_len
};
if baseline_len > 0 && (len_diff as f64 / baseline_len as f64) > 0.1 {
return true;
}
let payload_lower = payload.to_lowercase();
let response_lower = response.body.to_lowercase();
let baseline_lower = baseline.body.to_lowercase();
if response_lower.contains(&payload_lower) && !baseline_lower.contains(&payload_lower) {
return true;
}
let error_indicators = ["error", "exception", "invalid", "syntax", "unexpected"];
for indicator in &error_indicators {
if response_lower.contains(indicator) && !baseline_lower.contains(indicator) {
return true;
}
}
if response.duration_ms > baseline.duration_ms + 500 {
return true;
}
false
}
pub fn detect_technology(tech: &str, html_body: &str, headers: &HashMap<String, String>) -> bool {
let body_lower = html_body.to_lowercase();
match tech {
"firebase" => {
html_body.contains("firebase")
|| html_body.contains("firebaseapp.com")
|| html_body.contains("__firebase")
|| html_body.contains("/__/firebase/")
}
"aws" => {
html_body.contains("amazonaws.com")
|| html_body.contains("aws-amplify")
|| html_body.contains("s3.")
|| body_lower.contains("cloudfront")
}
"azure" => {
html_body.contains("azure")
|| html_body.contains("blob.core.windows.net")
|| html_body.contains("azurewebsites.net")
}
"gcp" | "google-cloud" => {
html_body.contains("storage.googleapis.com")
|| html_body.contains("cloudrun.app")
|| html_body.contains("appspot.com")
}
"docker" | "container" => {
headers
.get("server")
.map(|s| s.contains("docker"))
.unwrap_or(false)
|| html_body.contains("/.well-known/docker")
|| html_body.contains(":2375")
|| html_body.contains(":2376")
}
"kubernetes" => {
html_body.contains("kubernetes")
|| html_body.contains("k8s.io")
|| html_body.contains(":6443")
|| html_body.contains(":8001")
}
_ => true, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vue_spa_detection() {
let body = r#"<!DOCTYPE html><html><head></head><body><div id="app"></div><script src="/js/chunk-vendors.js"></script><script src="/js/app.js"></script></body></html>"#;
let response = HttpResponse {
status_code: 200,
body: body.to_string(),
headers: HashMap::new(),
duration_ms: 100,
};
let characteristics = AppCharacteristics::from_response(&response, "https://example.com");
assert!(characteristics.is_spa);
assert!(matches!(
characteristics.app_type,
AppType::SinglePageApp(SpaFramework::Vue)
));
assert!(characteristics.should_skip_injection_tests());
}
#[test]
fn test_react_spa_detection() {
let body = r#"<!DOCTYPE html><html><head></head><body><div id="root" data-reactroot=""></div><script src="/static/js/main.chunk.js"></script></body></html>"#;
let response = HttpResponse {
status_code: 200,
body: body.to_string(),
headers: HashMap::new(),
duration_ms: 100,
};
let characteristics = AppCharacteristics::from_response(&response, "https://example.com");
assert!(characteristics.is_spa);
assert!(matches!(
characteristics.app_type,
AppType::SinglePageApp(SpaFramework::React)
));
}
#[test]
fn test_real_oauth_detection() {
let body = r#"<a href="https://accounts.google.com/o/oauth2/auth?client_id=123">Login with Google</a>"#;
let response = HttpResponse {
status_code: 200,
body: body.to_string(),
headers: HashMap::new(),
duration_ms: 100,
};
let characteristics = AppCharacteristics::from_response(&response, "https://example.com");
assert!(characteristics.has_oauth);
assert!(!characteristics.should_skip_oauth_tests());
}
#[test]
fn test_fake_oauth_detection() {
let body = r#"<p>Learn about OAuth 2.0 in our documentation. Example: /oauth/authorize?client_id=...</p>"#;
let response = HttpResponse {
status_code: 200,
body: body.to_string(),
headers: HashMap::new(),
duration_ms: 100,
};
let characteristics = AppCharacteristics::from_response(&response, "https://example.com");
assert!(!characteristics.has_oauth);
assert!(characteristics.should_skip_oauth_tests());
}
#[test]
fn test_dangerous_reflection() {
let response = HttpResponse {
status_code: 200,
body: "<div>User: <script>alert(1)</script></div>".to_string(),
headers: HashMap::new(),
duration_ms: 100,
};
assert!(is_payload_reflected_dangerously(&response, "alert(1)"));
}
#[test]
fn test_safe_reflection_in_bundle() {
let response = HttpResponse {
status_code: 200,
body: format!("<script src='/app.js'></script>{}", "a".repeat(200_000)),
headers: HashMap::new(),
duration_ms: 100,
};
assert!(!is_payload_reflected_dangerously(&response, "alert(1)"));
}
}
fn has_file_upload(body: &str, body_lower: &str) -> bool {
if body_lower.contains("type=\"file\"")
|| body_lower.contains("type='file'")
|| body_lower.contains("input file")
|| body_lower.contains("multipart/form-data")
{
return true;
}
if body_lower.contains("upload")
&& (body_lower.contains("drag")
|| body_lower.contains("drop")
|| body_lower.contains("choose file")
|| body_lower.contains("select file"))
{
return true;
}
if body.contains("dropzone")
|| body.contains("filepond")
|| body.contains("uppy")
|| body.contains("fine-uploader")
{
return true;
}
false
}