santh-orchestrator-cli 0.1.0

CLI for the Santh discovery orchestrator: cold URL to loginflow, appmap, and scanner fan-out
//! `santhorchestrator`: cold URL → loginflow → appmap → scanner fan-out.

use std::path::PathBuf;

use anyhow::Context;
use clap::{Parser, Subcommand};
use orchestrator_core::{
    load_credentials_file, parse_scanner_list, Orchestrator, ScanMode, ScanRequest,
};
use tracing::info;
use url::Url;

#[derive(Parser)]
#[command(
    name = "santhorchestrator",
    version,
    about = "Santh discovery orchestrator (loginflow → appmap → scanners)"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,

    #[arg(long, default_value = "info", global = true)]
    log_level: String,
}

#[derive(Subcommand)]
enum Command {
    /// Run the full discovery pipeline against a target URL.
    Scan {
        /// Target URL (cold start).
        url: String,

        /// Credentials TOML (`[[role]]` entries).
        #[arg(long)]
        creds: Option<PathBuf>,

        /// Comma-separated scanners: scald, bolascan, gossan.
        #[arg(long, default_value = "scald,bolascan")]
        scanners: String,

        /// Dry-run: skip external scanner CLIs (library probes only).
        #[arg(long)]
        dry_run: bool,

        /// Enable captchaforge-backed login (requires `captchaforge` compile feature).
        #[arg(long)]
        captchaforge: bool,
    },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::new(&cli.log_level))
        .init();

    match cli.command {
        Command::Scan {
            url,
            creds,
            scanners,
            dry_run,
            captchaforge,
        } => run_scan(url, creds, scanners, dry_run, captchaforge).await,
    }
}

async fn run_scan(
    url: String,
    creds_path: Option<PathBuf>,
    scanners_raw: String,
    dry_run: bool,
    use_captchaforge: bool,
) -> anyhow::Result<()> {
    let target = Url::parse(&url).context("invalid target URL")?;
    let scanner_ids = parse_scanner_list(&scanners_raw).map_err(anyhow::Error::msg)?;

    let credentials = creds_path
        .as_ref()
        .map(|p| load_credentials_file(p))
        .transpose()
        .map_err(|e| anyhow::anyhow!("{e}"))?;

    let orchestrator = Orchestrator::builder()
        .build()
        .map_err(|e| anyhow::anyhow!("{e}"))?;

    let mode = if dry_run {
        ScanMode::DryRun
    } else {
        ScanMode::RealTarget
    };

    let req = ScanRequest {
        target,
        credentials,
        scanners: scanner_ids,
        mode,
        use_captchaforge,
    };

    info!("starting orchestrated scan");
    let result = orchestrator.scan(req).await?;

    info!(
        phase = ?result.pipeline.phase,
        endpoints = result.appmap.endpoints().len(),
        findings = result.findings.len(),
        "scan complete"
    );

    for (name, report) in &result.scanner_reports {
        println!("scanner {name}: {} findings ({})", report.findings_count, report.detail);
    }

    if !result.findings.is_empty() {
        let json = serde_json::to_string_pretty(&result.findings)?;
        println!("{json}");
    }

    Ok(())
}