flintbase 0.3.1

Google / Firebase API key analyzer and APK secret scanner — tests keys against 20+ endpoints and extracts hardcoded credentials from Android apps
use indexmap::IndexMap;

use indicatif::{ProgressBar, ProgressStyle};
use reqwest::blocking::Client;
use serde_json::Value;

use crate::config::TestResult;

/// Descriptor for a single Google Cloud API test.
struct GoogleApiTest {
    name: &'static str,
    url_template: &'static str,
    method: Method,
    body: Option<Value>,
    /// Returns true if the response indicates the API is enabled for this key.
    success_check: fn(status: u16, body: &Option<Value>, content_len: usize) -> bool,
}

#[derive(Clone, Copy)]
enum Method {
    Get,
    Post,
}

fn google_api_tests() -> Vec<GoogleApiTest> {
    vec![
        GoogleApiTest {
            name: "Static Maps API",
            url_template: "https://maps.googleapis.com/maps/api/staticmap?center=0,0&zoom=0&size=1x1&key={key}",
            method: Method::Get,
            body: None,
            success_check: |status, _body, content_len| status == 200 && content_len > 100,
        },
        GoogleApiTest {
            name: "Geocoding API",
            url_template: "https://maps.googleapis.com/maps/api/geocode/json?address=1600+Amphitheatre&key={key}",
            method: Method::Get,
            body: None,
            success_check: |_status, body, _| {
                body.as_ref()
                    .and_then(|b| b.get("status"))
                    .and_then(|s| s.as_str())
                    == Some("OK")
            },
        },
        GoogleApiTest {
            name: "Places Text Search",
            url_template: "https://maps.googleapis.com/maps/api/place/textsearch/json?query=restaurant&key={key}",
            method: Method::Get,
            body: None,
            success_check: |_status, body, _| {
                let s = body
                    .as_ref()
                    .and_then(|b| b.get("status"))
                    .and_then(|s| s.as_str())
                    .unwrap_or("");
                s == "OK" || s == "ZERO_RESULTS"
            },
        },
        GoogleApiTest {
            name: "YouTube Data API v3",
            url_template: "https://www.googleapis.com/youtube/v3/search?part=snippet&q=test&maxResults=1&key={key}",
            method: Method::Get,
            body: None,
            success_check: |status, body, _| {
                status == 200
                    && body
                        .as_ref()
                        .map(|b| b.get("items").is_some())
                        .unwrap_or(false)
            },
        },
        GoogleApiTest {
            name: "Gemini API",
            url_template: "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={key}",
            method: Method::Post,
            body: Some(serde_json::json!({
                "contents": [{"parts": [{"text": "ping"}]}]
            })),
            success_check: |status, body, _| {
                status == 200
                    || body
                        .as_ref()
                        .map(|b| b.get("candidates").is_some())
                        .unwrap_or(false)
            },
        },
        GoogleApiTest {
            name: "Google Translate v2",
            url_template: "https://translation.googleapis.com/language/translate/v2?key={key}",
            method: Method::Post,
            body: Some(serde_json::json!({"q": "Hello", "target": "es"})),
            success_check: |status, body, _| {
                status == 200
                    && body
                        .as_ref()
                        .map(|b| b.to_string().contains("translations"))
                        .unwrap_or(false)
            },
        },
        GoogleApiTest {
            name: "Directions API",
            url_template: "https://maps.googleapis.com/maps/api/directions/json?origin=NYC&destination=Boston&key={key}",
            method: Method::Get,
            body: None,
            success_check: |_status, body, _| {
                let s = body
                    .as_ref()
                    .and_then(|b| b.get("status"))
                    .and_then(|s| s.as_str())
                    .unwrap_or("");
                s == "OK" || s == "ZERO_RESULTS"
            },
        },
    ]
}

/// Run all Google Cloud API tests and return ordered results.
pub fn run_google_api_tests(
    client: &Client,
    key: &str,
) -> IndexMap<String, TestResult> {
    let tests = google_api_tests();
    let mut results = IndexMap::new();

    let pb = ProgressBar::new(tests.len() as u64);
    pb.set_style(
        ProgressStyle::with_template("Testing Google APIs... [{bar:30.cyan/dim}] {pos}/{len}")
            .unwrap()
            .progress_chars("━╸─"),
    );

    for test in &tests {
        let url = test.url_template.replace("{key}", key);

        let outcome = match test.method {
            Method::Get => client.get(&url).send(),
            Method::Post => {
                let mut req = client.post(&url);
                if let Some(ref body) = test.body {
                    req = req.json(body);
                }
                req.send()
            }
        };

        let result = match outcome {
            Ok(resp) => {
                let status = resp.status().as_u16();
                // Read raw bytes so we can measure content length and also parse JSON
                let bytes = resp.bytes().unwrap_or_default();
                let content_len = bytes.len();
                let json_body: Option<Value> = serde_json::from_slice(&bytes).ok();

                let is_success = (test.success_check)(status, &json_body, content_len);

                if is_success {
                    TestResult::ok(status, "Works")
                } else {
                    let (error_msg, hint) = classify_failure(status, &json_body);
                    TestResult {
                        success: false,
                        status_code: Some(status),
                        error: Some(error_msg),
                        detail: hint,
                        extra: Default::default(),
                    }
                }
            }
            Err(e) => TestResult::fail(
                None,
                format!("Network: {}", &e.to_string().chars().take(50).collect::<String>()),
                None,
            ),
        };

        results.insert(test.name.to_string(), result);
        pb.inc(1);
    }

    pb.finish_and_clear();
    results
}

fn classify_failure(status: u16, body: &Option<Value>) -> (String, Option<String>) {
    match status {
        403 => (
            "403 Forbidden".to_string(),
            Some("restricted/not enabled/billing".to_string()),
        ),
        401 => (
            "401 Unauthorized".to_string(),
            Some("key invalid/deleted".to_string()),
        ),
        429 => (
            "429 Rate Limited".to_string(),
            Some("quota exceeded".to_string()),
        ),
        _ => {
            if let Some(ref b) = body {
                if let Some(err) = b.get("error") {
                    let msg = if let Some(m) = err.get("message").and_then(|m| m.as_str()) {
                        m.to_string()
                    } else {
                        err.to_string()
                    };
                    return (msg, None);
                }
            }
            (format!("HTTP {}", status), None)
        }
    }
}