use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use serde::Serialize;
use std::time::Duration;
use truestack::{favicon, fingerprints, security_headers};
#[derive(Parser)]
#[command(name = "truestack")]
#[command(about = "Security-aware technology fingerprinting")]
struct Cli {
url: String,
#[arg(short, long, value_enum, default_value = "json")]
format: OutputFormat,
#[arg(short, long)]
rules_dir: Option<String>,
#[arg(long)]
favicon: bool,
#[arg(short, long, default_value = "10")]
timeout: u64,
}
#[derive(Clone, ValueEnum)]
enum OutputFormat {
Json,
Text,
}
#[derive(Serialize)]
struct Report {
url: String,
technologies: Vec<truestack::Technology>,
security_headers: Vec<truestack::Finding>,
favicon_hash: Option<i32>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(cli.timeout))
.danger_accept_invalid_certs(true)
.build()
.context("Failed to build HTTP client")?;
let resp = client
.get(&cli.url)
.send()
.await
.context("Failed to fetch URL")?;
let status_code = resp.status().as_u16();
let headers: Vec<(String, String)> = resp
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = resp.text().await.unwrap_or_default();
let mut favicon_hash = None;
if cli.favicon {
let parsed_url = reqwest::Url::parse(&cli.url)?;
let fav_url = parsed_url.join("/favicon.ico")?;
if let Ok(fav_resp) = client.get(fav_url).send().await {
if fav_resp.status().is_success() {
if let Ok(bytes) = fav_resp.bytes().await {
favicon_hash = Some(favicon::shodan_favicon_hash(&bytes));
}
}
}
}
let mut engine = fingerprints::RuleEngine::embedded().clone();
if let Some(dir) = &cli.rules_dir {
match fingerprints::RuleEngine::from_directory(dir) {
Ok(custom) => engine.merge(custom),
Err(e) => eprintln!("Warning: failed to load custom rules from {}: {}", dir, e),
}
}
let mut technologies = fingerprints::detect_with_engine(&headers, &body, favicon_hash, &engine);
if let Some(waf_tech) = truestack::waf::detect(status_code, &headers, body.as_bytes()) {
technologies.push(waf_tech);
}
let _ = truestack::behavior::identify(&client, &cli.url, &mut technologies).await;
technologies = truestack::postprocess::apply(technologies, &engine.rules);
truestack::version_intel::assess(&mut technologies, &headers);
let security_headers = security_headers::audit(&headers);
let report = Report {
url: cli.url.clone(),
technologies,
security_headers,
favicon_hash,
};
match cli.format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
OutputFormat::Text => {
println!("Target: {}", report.url);
println!("\nTechnologies:");
if report.technologies.is_empty() {
println!(" (none detected)");
}
for t in &report.technologies {
println!(
" - {} (Category: {:?}, Version: {})",
t.name,
t.category,
t.version.as_deref().unwrap_or("unknown")
);
}
println!("\nSecurity Headers Findings:");
if report.security_headers.is_empty() {
println!(" (no findings)");
}
for f in &report.security_headers {
println!(" [{:?}] {}: {}", f.severity(), f.title(), f.detail());
}
if let Some(hash) = report.favicon_hash {
println!("\nFavicon Hash: {}", hash);
}
}
}
Ok(())
}