use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use colored::Colorize;
use regex::Regex;
use serde_json::Value;
use crate::config::FirebaseConfig;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ExtractedCredential {
pub kind: CredentialKind,
pub value: String,
pub source: String, pub context: String, }
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CredentialKind {
GoogleApiKey,
FirebaseAppId,
FirebaseProjectId,
GcmSenderId,
FirebaseDatabaseUrl,
StorageBucket,
OAuthClientId,
OAuthClientSecret,
GenericSecret,
}
impl std::fmt::Display for CredentialKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CredentialKind::GoogleApiKey => write!(f, "Google API Key"),
CredentialKind::FirebaseAppId => write!(f, "Firebase App ID"),
CredentialKind::FirebaseProjectId => write!(f, "Firebase Project ID"),
CredentialKind::GcmSenderId => write!(f, "GCM Sender ID"),
CredentialKind::FirebaseDatabaseUrl => write!(f, "Firebase Database URL"),
CredentialKind::StorageBucket => write!(f, "Storage Bucket"),
CredentialKind::OAuthClientId => write!(f, "OAuth Client ID"),
CredentialKind::OAuthClientSecret => write!(f, "OAuth Client Secret"),
CredentialKind::GenericSecret => write!(f, "Generic Secret"),
}
}
}
#[derive(Debug, Default)]
pub struct ExtractedCredentials {
pub credentials: Vec<ExtractedCredential>,
}
impl ExtractedCredentials {
pub fn values_of(&self, kind: &CredentialKind) -> Vec<&str> {
let mut seen = HashSet::new();
self.credentials
.iter()
.filter(|c| &c.kind == kind)
.filter(|c| seen.insert(&c.value))
.map(|c| c.value.as_str())
.collect()
}
pub fn build_firebase_configs(&self) -> Vec<FirebaseConfig> {
let api_keys = self.values_of(&CredentialKind::GoogleApiKey);
if api_keys.is_empty() {
return Vec::new();
}
let project_ids = self.values_of(&CredentialKind::FirebaseProjectId);
let app_ids = self.values_of(&CredentialKind::FirebaseAppId);
let sender_ids = self.values_of(&CredentialKind::GcmSenderId);
let db_urls = self.values_of(&CredentialKind::FirebaseDatabaseUrl);
let buckets = self.values_of(&CredentialKind::StorageBucket);
let mut by_source: HashMap<&str, Vec<&ExtractedCredential>> = HashMap::new();
for cred in &self.credentials {
by_source.entry(&cred.source).or_default().push(cred);
}
let mut configs = Vec::new();
for key in &api_keys {
let key_sources: Vec<&str> = self
.credentials
.iter()
.filter(|c| c.kind == CredentialKind::GoogleApiKey && c.value == *key)
.map(|c| c.source.as_str())
.collect();
let colocated_project = find_colocated(&by_source, &key_sources, &CredentialKind::FirebaseProjectId);
let colocated_app = find_colocated(&by_source, &key_sources, &CredentialKind::FirebaseAppId);
let colocated_sender = find_colocated(&by_source, &key_sources, &CredentialKind::GcmSenderId);
let colocated_db = find_colocated(&by_source, &key_sources, &CredentialKind::FirebaseDatabaseUrl);
let colocated_bucket = find_colocated(&by_source, &key_sources, &CredentialKind::StorageBucket);
let project_id = colocated_project
.or_else(|| if project_ids.len() == 1 { Some(project_ids[0]) } else { None })
.map(|s| s.to_string());
let app_id = colocated_app
.or_else(|| if app_ids.len() == 1 { Some(app_ids[0]) } else { None })
.map(|s| s.to_string());
let sender_id = colocated_sender
.or_else(|| if sender_ids.len() == 1 { Some(sender_ids[0]) } else { None })
.map(|s| s.to_string());
let database_url = colocated_db
.or_else(|| if db_urls.len() == 1 { Some(db_urls[0]) } else { None })
.map(|s| s.to_string());
let storage_bucket = colocated_bucket
.or_else(|| if buckets.len() == 1 { Some(buckets[0]) } else { None })
.map(|s| s.to_string());
let project_id = project_id.or_else(|| {
database_url.as_ref().and_then(|url| {
url.strip_prefix("https://")
.and_then(|s| s.strip_suffix("-default-rtdb.firebaseio.com"))
.or_else(|| url.strip_prefix("https://").and_then(|s| s.strip_suffix(".firebaseio.com")))
.map(|s| s.to_string())
})
});
configs.push(FirebaseConfig {
api_key: key.to_string(),
app_id,
project_id,
project_number: sender_id.clone(),
gcm_sender_id: sender_id,
storage_bucket,
database_url,
});
}
configs
}
}
fn find_colocated<'a>(
by_source: &HashMap<&str, Vec<&'a ExtractedCredential>>,
key_sources: &[&str],
kind: &CredentialKind,
) -> Option<&'a str> {
for src in key_sources {
if let Some(creds) = by_source.get(src) {
for c in creds {
if &c.kind == kind {
return Some(&c.value);
}
}
}
}
None
}
pub fn parse_noseyparker_json(json_str: &str) -> anyhow::Result<ExtractedCredentials> {
let findings: Vec<Value> = serde_json::from_str(json_str)?;
let mut result = ExtractedCredentials::default();
for finding in &findings {
let rule_name = finding
.get("rule_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let matches = finding
.get("matches")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for m in &matches {
let matching_text = m
.get("snippet")
.and_then(|s| s.get("matching"))
.and_then(|v| v.as_str())
.unwrap_or("");
let group_text = m
.get("groups")
.and_then(|g| g.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.and_then(|b64| BASE64.decode(b64).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
let secret_value = if !group_text.is_empty() {
&group_text
} else {
matching_text
};
let clean_value = secret_value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if clean_value.is_empty() {
continue;
}
let source = m
.get("provenance")
.and_then(|p| p.as_array())
.and_then(|arr| arr.first())
.and_then(|p| p.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let kind = classify_credential(rule_name, &clean_value);
result.credentials.push(ExtractedCredential {
kind,
value: clean_value,
source,
context: rule_name.to_string(),
});
}
}
Ok(result)
}
fn classify_credential(rule_name: &str, value: &str) -> CredentialKind {
let rule_lower = rule_name.to_lowercase();
if rule_lower.contains("google api key") || value.starts_with("AIzaSy") {
return CredentialKind::GoogleApiKey;
}
if rule_lower.contains("oauth") || rule_lower.contains("client secret") {
if value.contains(".apps.googleusercontent.com") {
return CredentialKind::OAuthClientId;
}
return CredentialKind::OAuthClientSecret;
}
if Regex::new(r"^\d+:\d+:(android|ios|web):[a-f0-9]+$")
.unwrap()
.is_match(value)
{
return CredentialKind::FirebaseAppId;
}
if Regex::new(r"^\d{10,}$").unwrap().is_match(value)
&& (rule_lower.contains("sender") || rule_lower.contains("gcm"))
{
return CredentialKind::GcmSenderId;
}
CredentialKind::GenericSecret
}
pub fn scan_decompiled_configs(decompiled_dir: &Path) -> ExtractedCredentials {
let mut result = ExtractedCredentials::default();
let gs_files = find_files_by_name(decompiled_dir, "google-services.json");
for path in &gs_files {
if let Ok(content) = std::fs::read_to_string(path) {
parse_google_services_json(&content, path, &mut result);
}
}
let xml_files = find_files_by_name(decompiled_dir, "strings.xml");
for path in &xml_files {
if let Ok(content) = std::fs::read_to_string(path) {
parse_strings_xml(&content, path, &mut result);
}
}
scan_files_for_patterns(decompiled_dir, &mut result);
result
}
fn parse_google_services_json(
content: &str,
path: &Path,
result: &mut ExtractedCredentials,
) {
let source = path.display().to_string();
let data: Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => return,
};
if let Some(pi) = data.get("project_info") {
if let Some(pid) = pi.get("project_id").and_then(|v| v.as_str()) {
push_cred(result, CredentialKind::FirebaseProjectId, pid, &source, "google-services.json → project_info.project_id");
}
if let Some(pn) = pi.get("project_number").and_then(|v| v.as_str()) {
push_cred(result, CredentialKind::GcmSenderId, pn, &source, "google-services.json → project_info.project_number");
}
if let Some(sb) = pi.get("storage_bucket").and_then(|v| v.as_str()) {
if !sb.is_empty() {
push_cred(result, CredentialKind::StorageBucket, sb, &source, "google-services.json → project_info.storage_bucket");
}
}
if let Some(db) = pi.get("firebase_url").and_then(|v| v.as_str()) {
if !db.is_empty() {
push_cred(result, CredentialKind::FirebaseDatabaseUrl, db, &source, "google-services.json → project_info.firebase_url");
}
}
}
if let Some(clients) = data.get("client").and_then(|v| v.as_array()) {
for client in clients {
if let Some(app_id) = client
.get("client_info")
.and_then(|ci| ci.get("mobilesdk_app_id"))
.and_then(|v| v.as_str())
{
push_cred(result, CredentialKind::FirebaseAppId, app_id, &source, "google-services.json → client.mobilesdk_app_id");
}
if let Some(keys) = client.get("api_key").and_then(|v| v.as_array()) {
for key_obj in keys {
if let Some(key) = key_obj.get("current_key").and_then(|v| v.as_str()) {
push_cred(result, CredentialKind::GoogleApiKey, key, &source, "google-services.json → client.api_key");
}
}
}
if let Some(oauths) = client.get("oauth_client").and_then(|v| v.as_array()) {
for oa in oauths {
if let Some(cid) = oa.get("client_id").and_then(|v| v.as_str()) {
push_cred(result, CredentialKind::OAuthClientId, cid, &source, "google-services.json → oauth_client.client_id");
}
}
}
}
}
}
fn parse_strings_xml(content: &str, path: &Path, result: &mut ExtractedCredentials) {
let source = path.display().to_string();
let patterns: &[(&str, CredentialKind)] = &[
("google_api_key", CredentialKind::GoogleApiKey),
("google_app_id", CredentialKind::FirebaseAppId),
("gcm_defaultSenderId", CredentialKind::GcmSenderId),
("project_id", CredentialKind::FirebaseProjectId),
("firebase_database_url", CredentialKind::FirebaseDatabaseUrl),
("google_storage_bucket", CredentialKind::StorageBucket),
("default_web_client_id", CredentialKind::OAuthClientId),
("google_crash_reporting_api_key", CredentialKind::GoogleApiKey),
];
let re = Regex::new(r#"<string\s+name="([^"]+)"[^>]*>([^<]+)</string>"#).unwrap();
for cap in re.captures_iter(content) {
let name = &cap[1];
let value = &cap[2];
for (pattern_name, kind) in patterns {
if name == *pattern_name {
push_cred(result, kind.clone(), value, &source, &format!("strings.xml → {}", name));
break;
}
}
}
}
fn scan_files_for_patterns(dir: &Path, result: &mut ExtractedCredentials) {
let api_key_re = Regex::new(r"AIzaSy[a-zA-Z0-9_-]{33}").unwrap();
let app_id_re = Regex::new(r"\d+:\d+:(android|ios|web):[a-f0-9]+").unwrap();
let db_url_re = Regex::new(r"https://[a-zA-Z0-9-]+-default-rtdb\.firebaseio\.com").unwrap();
let bucket_re = Regex::new(r"[a-zA-Z0-9-]+\.appspot\.com").unwrap();
let extensions = &["xml", "json", "java", "kt", "properties", "cfg", "txt"];
visit_files(dir, &mut |path: &Path| {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !extensions.contains(&ext) {
return;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
let source = path.display().to_string();
for m in api_key_re.find_iter(&content) {
push_cred_dedup(result, CredentialKind::GoogleApiKey, m.as_str(), &source, "regex scan");
}
for m in app_id_re.find_iter(&content) {
push_cred_dedup(result, CredentialKind::FirebaseAppId, m.as_str(), &source, "regex scan");
}
for m in db_url_re.find_iter(&content) {
push_cred_dedup(result, CredentialKind::FirebaseDatabaseUrl, m.as_str(), &source, "regex scan");
}
for m in bucket_re.find_iter(&content) {
let val = m.as_str();
if val.contains("example") || val == "undefined.appspot.com" {
continue;
}
push_cred_dedup(result, CredentialKind::StorageBucket, val, &source, "regex scan");
}
});
}
pub fn print_extracted_summary(creds: &ExtractedCredentials) {
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table};
if creds.credentials.is_empty() {
println!("\n {} No Firebase/Google credentials found.", "⚠".yellow());
return;
}
let mut by_kind: HashMap<String, Vec<(&str, &str)>> = HashMap::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
for c in &creds.credentials {
let key = (c.kind.to_string(), c.value.clone());
if seen.insert(key) {
by_kind
.entry(c.kind.to_string())
.or_default()
.push((&c.value, &c.source));
}
}
println!("\n{}", "Extracted Credentials".bright_magenta().bold());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.apply_modifier(UTF8_ROUND_CORNERS);
table.set_header(vec![
Cell::new("Type").fg(Color::Magenta),
Cell::new("Value"),
Cell::new("Source"),
]);
let display_order = [
"Google API Key",
"Firebase App ID",
"Firebase Project ID",
"GCM Sender ID",
"Firebase Database URL",
"Storage Bucket",
"OAuth Client ID",
"OAuth Client Secret",
"Generic Secret",
];
for kind_name in &display_order {
if let Some(entries) = by_kind.get(*kind_name) {
for (value, source) in entries {
let display_val = if value.len() > 50 {
format!("{}...{}", &value[..20], &value[value.len()-10..])
} else {
value.to_string()
};
let display_src = source
.rsplit('/')
.take(3)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("/");
table.add_row(vec![
Cell::new(kind_name).fg(Color::Cyan),
Cell::new(&display_val).fg(Color::Yellow),
Cell::new(&display_src),
]);
}
}
}
println!("{table}");
}
pub fn print_configs_to_test(configs: &[FirebaseConfig]) {
println!(
"\n{} Built {} Firebase configuration(s) for key testing:",
"▸".cyan(),
configs.len()
);
for (i, cfg) in configs.iter().enumerate() {
let masked_key = if cfg.api_key.len() > 16 {
format!("{}...{}", &cfg.api_key[..12], &cfg.api_key[cfg.api_key.len() - 4..])
} else {
cfg.api_key.clone()
};
println!(
" {}. Key: {} Project: {} App: {} Sender: {}",
i + 1,
masked_key.yellow(),
cfg.project_id.as_deref().unwrap_or("—").green(),
cfg.app_id.as_deref().unwrap_or("—").dimmed(),
cfg.gcm_sender_id.as_deref().unwrap_or("—").dimmed(),
);
}
}
fn push_cred(
result: &mut ExtractedCredentials,
kind: CredentialKind,
value: &str,
source: &str,
context: &str,
) {
let value = value.trim().to_string();
if value.is_empty() {
return;
}
result.credentials.push(ExtractedCredential {
kind,
value,
source: source.to_string(),
context: context.to_string(),
});
}
fn push_cred_dedup(
result: &mut ExtractedCredentials,
kind: CredentialKind,
value: &str,
source: &str,
context: &str,
) {
let value = value.trim().to_string();
if value.is_empty() {
return;
}
let dominated = result.credentials.iter().any(|c| {
c.kind == kind && c.value == value && c.source == source
});
if !dominated {
result.credentials.push(ExtractedCredential {
kind,
value,
source: source.to_string(),
context: context.to_string(),
});
}
}
fn find_files_by_name(dir: &Path, name: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
visit_files(dir, &mut |path: &Path| {
if path.file_name().and_then(|n| n.to_str()) == Some(name) {
found.push(path.to_path_buf());
}
});
found
}
fn visit_files(dir: &Path, visitor: &mut dyn FnMut(&Path)) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
visit_files(&path, visitor);
} else {
visitor(&path);
}
}
}
}