mod apk;
mod config;
mod firebase;
mod google;
mod parser;
mod preflight;
mod report;
mod scanner;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::Duration;
use clap::{Parser, Subcommand};
use colored::Colorize;
use config::FirebaseConfig;
#[derive(Parser, Debug)]
#[command(name = "flintbase", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
#[command(after_help = "\
EXAMPLES:
flintbase key AIzaSyXXXXXXXXXXXXXXXXXXXX
flintbase key AIzaSyXXXX --project-id my-project --app-id 1:123:android:abc
flintbase key --config my_app
flintbase key --list-configs")]
Key {
key: Option<String>,
#[arg(short = 'p', long = "project-id")]
project_id: Option<String>,
#[arg(short = 'a', long = "app-id")]
app_id: Option<String>,
#[arg(short = 's', long = "sender-id")]
sender_id: Option<String>,
#[arg(short = 'c', long = "config")]
config: Option<String>,
#[arg(long = "list-configs")]
list_configs: bool,
},
#[command(after_help = "\
EXAMPLES:
flintbase apk https://play.google.com/store/apps/details?id=com.example.app
flintbase apk com.example.app
flintbase apk https://play.google.com/store/apps/developer?id=Developer+Name
flintbase apk com.example.app --output-dir ./my_analysis --format json
flintbase apk com.example.app -v # verbose: show apkeep/jadx output")]
Apk {
input: String,
#[arg(short = 'o', long = "output-dir", default_value = "flintbase_output")]
output_dir: PathBuf,
#[arg(short = 'f', long = "format", default_value = "human")]
format: String,
#[arg(short = 'v', long = "verbose")]
verbose: bool,
},
#[command(after_help = "\
The scan command combines the APK pipeline with automatic key testing.
It downloads and decompiles the APK, scans for secrets with NoseyParker,
parses the results to extract Firebase/Google credentials, then runs
the full key analysis against every discovered API key.
EXAMPLES:
flintbase scan https://play.google.com/store/apps/details?id=com.example.app
flintbase scan com.example.app
flintbase scan com.example.app --output-dir ./analysis
flintbase scan com.example.app -v # verbose: show all tool output
flintbase scan com.example.app --save # save discovered configs for later use")]
Scan {
input: String,
#[arg(short = 'o', long = "output-dir", default_value = "flintbase_output")]
output_dir: PathBuf,
#[arg(short = 'v', long = "verbose")]
verbose: bool,
#[arg(long = "save")]
save: bool,
},
#[command(after_help = "\
Required tools for the APK pipeline:
apkeep - APK downloading (EFF, Rust-based)
jadx - APK decompilation to Java source
java - Required by jadx
noseyparker - Secret scanning
EXAMPLES:
flintbase setup # Check status of all dependencies
flintbase setup --install # Auto-detect platform and install missing tools")]
Setup {
#[arg(long = "install", short = 'i')]
install: bool,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Key {
key,
project_id,
app_id,
sender_id,
config,
list_configs,
} => run_key_command(key, project_id, app_id, sender_id, config, list_configs),
Commands::Apk {
input,
output_dir,
format,
verbose,
} => run_apk_command(&input, &output_dir, &format, verbose),
Commands::Scan {
input,
output_dir,
verbose,
save,
} => run_scan_command(&input, &output_dir, verbose, save),
Commands::Setup { install } => {
if install {
preflight::run_auto_install();
} else {
preflight::run_setup_check();
}
}
}
}
fn run_key_command(
key: Option<String>,
project_id: Option<String>,
app_id: Option<String>,
sender_id: Option<String>,
config_name: Option<String>,
list_configs: bool,
) {
if list_configs {
report::print_saved_configs();
return;
}
let (fb_config, app_name) = if let Some(ref cname) = config_name {
let store = config::load_saved_configs();
let saved = match store.configs.get(cname.as_str()) {
Some(c) => c.clone(),
None => {
eprintln!(
"Error: unknown config '{}'. Use --list-configs to see available options.",
cname
);
if !store.configs.is_empty() {
eprintln!("Available configs: {}", store.configs.keys().cloned().collect::<Vec<_>>().join(", "));
} else {
eprintln!("No saved configs found. Run `flintbase scan --save` to save configs from an APK scan.");
}
std::process::exit(1);
}
};
println!(
"\n{} {}",
"Using saved configuration:".green().bold(),
saved.name.bold()
);
let fb = saved.to_firebase_config();
(fb, Some(saved.name.clone()))
} else {
let api_key = if let Some(ref k) = key {
k.trim().to_string()
} else {
print!("Enter Google/Firebase API key → ");
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
};
if !api_key.starts_with("AIza") || api_key.len() < 30 {
eprintln!("Error: Invalid key format (should start with AIzaSy... and be >= 30 chars)");
std::process::exit(1);
}
let config = FirebaseConfig {
api_key,
app_id,
project_id,
project_number: None,
gcm_sender_id: sender_id,
storage_bucket: None,
database_url: None,
};
(config, None)
};
run_key_tests(&fb_config, app_name.as_deref());
}
fn run_key_tests(fb_config: &FirebaseConfig, app_name: Option<&str>) {
report::print_config_info(fb_config, app_name);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("Failed to build HTTP client");
let (firebase_results, project_info) =
firebase::run_firebase_deep_tests(&client, fb_config);
let google_results = google::run_google_api_tests(&client, &fb_config.api_key);
report::print_report(fb_config, &firebase_results, &google_results, &project_info);
}
fn run_apk_command(input: &str, output_dir: &PathBuf, format: &str, verbose: bool) {
let valid_formats = ["human", "json", "jsonl", "sarif"];
if !valid_formats.contains(&format) {
eprintln!(
"Error: Invalid format '{}'. Must be one of: {}",
format,
valid_formats.join(", ")
);
std::process::exit(1);
}
if let Err(e) = apk::run_apk_command(input, output_dir, format, verbose) {
eprintln!("\n\x1b[1;31mError:\x1b[0m {}", e);
std::process::exit(1);
}
}
fn run_scan_command(input: &str, output_dir: &PathBuf, verbose: bool, save: bool) {
if let Err(e) = preflight::ensure_apk_tools_available() {
eprintln!("\n\x1b[1;31mError:\x1b[0m {}", e);
std::process::exit(1);
}
println!(
"\n{}",
"flintBase Full Scan Pipeline".bright_cyan().bold()
);
println!("{}", "═".repeat(60));
println!("\n{}", "Phase 1: Resolve packages".bold());
let packages = match apk::download::parse_store_input(input) {
Ok(p) => p,
Err(e) => {
eprintln!("\n\x1b[1;31mError:\x1b[0m {}", e);
std::process::exit(1);
}
};
if packages.is_empty() {
eprintln!("\nError: No packages found to process.");
std::process::exit(1);
}
for package in &packages {
println!(
"\n{}",
"═".repeat(60)
);
println!(
"{} {}",
"Scanning package:".bright_magenta().bold(),
package.bold()
);
println!("{}", "═".repeat(60));
let ws = apk::ApkWorkspace::new(output_dir, package);
if let Err(e) = ws.create_dirs() {
eprintln!(" Failed to create workspace: {}", e);
continue;
}
println!("\n{}", "Phase 2: Download APK".bold());
let apk_path = match apk::download::download_apk(package, &ws.apk_dir, verbose) {
Ok(p) => {
println!(" {} APK saved: {}", "✓".green(), p.display());
p
}
Err(e) => {
eprintln!(" {} Download failed: {}", "✗".red(), e);
continue;
}
};
println!("\n{}", "Phase 3: Decompile APK".bold());
let decompiled_dir = match apk::decompile::decompile_apk(&apk_path, &ws.decompiled_dir, verbose) {
Ok(d) => d,
Err(e) => {
eprintln!(" {} Decompilation failed: {}", "✗".red(), e);
continue;
}
};
println!("\n{}", "Phase 4: Secret scan (NoseyParker)".bold());
if let Err(e) = scanner::scan_directory(&decompiled_dir, &ws.datastore_dir) {
eprintln!(" {} Scan failed: {}", "✗".red(), e);
continue;
}
let report_file = ws.report_dir.join("secrets.txt");
let _ = scanner::generate_report(&ws.datastore_dir, "human", Some(&report_file));
println!("\n{}", "Phase 5: Extract credentials".bold());
let mut all_creds = parser::ExtractedCredentials::default();
match scanner::generate_json_report(&ws.datastore_dir) {
Ok(json_str) => {
match parser::parse_noseyparker_json(&json_str) {
Ok(np_creds) => {
println!(
" {} Extracted {} credential(s) from NoseyParker findings",
"▸".cyan(),
np_creds.credentials.len()
);
all_creds.credentials.extend(np_creds.credentials);
}
Err(e) => {
eprintln!(
" {} Failed to parse NoseyParker JSON: {}",
"⚠".yellow(),
e
);
}
}
}
Err(e) => {
eprintln!(
" {} Failed to generate NoseyParker JSON: {}",
"⚠".yellow(),
e
);
}
}
let config_creds = parser::scan_decompiled_configs(&decompiled_dir);
println!(
" {} Extracted {} credential(s) from config files (google-services.json, strings.xml, etc.)",
"▸".cyan(),
config_creds.credentials.len()
);
all_creds.credentials.extend(config_creds.credentials);
parser::print_extracted_summary(&all_creds);
let configs = all_creds.build_firebase_configs();
if configs.is_empty() {
println!(
"\n {} No Google API keys found — skipping key analysis.",
"⚠".yellow()
);
println!(" Full NoseyParker report saved to: {}", report_file.display());
continue;
}
parser::print_configs_to_test(&configs);
println!(
"\n{}",
"Phase 6: API Key Analysis".bold()
);
println!("{}", "─".repeat(60));
for (i, fb_config) in configs.iter().enumerate() {
println!(
"\n{} Testing key {}/{}: {}...{}",
"▶".bright_cyan().bold(),
i + 1,
configs.len(),
&fb_config.api_key[..12],
&fb_config.api_key[fb_config.api_key.len().saturating_sub(4)..],
);
run_key_tests(fb_config, None);
}
if save && !configs.is_empty() {
match config::save_firebase_configs(package, &configs) {
Ok(n) => {
println!(
"\n {} Saved {} config(s) to {}",
"✓".green(),
n,
config::config_file_path().display()
);
println!(
" {} Re-test later with: {}",
"▸".cyan(),
format!("flintbase key --config {}", package).bold()
);
println!(
" {} Edit the file to tweak values before re-testing.",
"▸".cyan()
);
}
Err(e) => {
eprintln!(
"\n {} Failed to save configs: {}",
"⚠".yellow(),
e
);
}
}
}
println!("\n{}", "═".repeat(60));
println!("{}", "Scan Complete".bright_cyan().bold());
println!(" Workspace: {}", ws.base_dir.display());
println!(" APK: {}", ws.apk_dir.display());
println!(" Decompiled: {}", ws.decompiled_dir.display());
println!(" NP Report: {}", report_file.display());
println!(" Keys tested: {}", configs.len());
if save && !configs.is_empty() {
println!(
" Saved to: {}",
config::config_file_path().display()
);
}
}
}