use crate::builder::DalfoxBuilder;
use crate::error::DalfoxError;
use crate::types::{DalfoxFinding, DalfoxJsonEnvelope, DalfoxResult};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DalfoxOutputFormat {
Json,
Jsonl,
}
pub struct DalfoxRunner {
config: DalfoxBuilder,
}
impl DalfoxRunner {
pub(crate) fn new(config: DalfoxBuilder) -> Self {
Self { config }
}
pub fn binary_path(&self) -> &str {
self.config.binary_path.as_deref().unwrap_or("dalfox")
}
pub fn is_available(&self) -> bool {
std::process::Command::new(self.binary_path())
.arg("-V")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub async fn version(&self) -> Result<String, DalfoxError> {
let output = Command::new(self.binary_path())
.arg("-V")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| map_spawn_error(e, self.binary_path()))?;
if !output.status.success() {
return Err(DalfoxError::BinaryNotFound {
path: self.binary_path().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn map_spawn_result(
&self,
result: Result<tokio::process::Child, std::io::Error>,
) -> Result<tokio::process::Child, DalfoxError> {
result.map_err(|e| map_spawn_error(e, self.binary_path()))
}
fn append_common_args(&self, args: &mut Vec<String>, format: DalfoxOutputFormat) {
args.push("--format".into());
args.push(match format {
DalfoxOutputFormat::Json => "json",
DalfoxOutputFormat::Jsonl => "jsonl",
}
.into());
if let Some(cookie) = &self.config.cookie {
args.push("--cookies".into());
args.push(cookie.clone());
}
if let Some(raw_file) = &self.config.cookie_from_raw {
args.push("--cookie-from-raw".into());
args.push(raw_file.clone());
}
if let Some(ua) = &self.config.user_agent {
args.push("--user-agent".into());
args.push(ua.clone());
}
if let Some(proxy) = &self.config.proxy {
args.push("--proxy".into());
args.push(proxy.clone());
}
if let Some(timeout) = self.config.request_timeout_secs {
args.push("--timeout".into());
args.push(timeout.to_string());
}
if let Some(delay) = self.config.delay_ms {
args.push("--delay".into());
args.push(delay.to_string());
}
if let Some(workers) = self.config.workers {
args.push("--workers".into());
args.push(workers.to_string());
}
if self.config.skip_mining_all {
args.push("--skip-mining".into());
}
if self.config.skip_mining_dom {
args.push("--skip-mining-dom".into());
}
if self.config.skip_mining_dict {
args.push("--skip-mining-dict".into());
}
if self.config.only_discovery {
args.push("--only-discovery".into());
}
if self.config.only_custom_payload {
args.push("--only-custom-payload".into());
}
if self.config.follow_redirects {
args.push("--follow-redirects".into());
}
if self.config.waf_evasion {
args.push("--waf-evasion".into());
}
if self.config.debug_mode {
args.push("--debug".into());
}
if self.config.silence {
args.push("--silence".into());
}
if let Some(p) = &self.config.param {
args.push("--param".into());
args.push(p.clone());
}
if let Some(dict) = &self.config.mining_dict {
args.push("--mining-dict-word".into());
args.push(dict.clone());
}
if let Some(m) = &self.config.method {
args.push("--method".into());
args.push(m.clone());
}
if let Some(d) = &self.config.data {
args.push("--data".into());
args.push(d.clone());
}
if let Some(codes) = &self.config.ignore_return_codes {
args.push("--ignore-return".into());
args.push(codes.clone());
}
if let Some(url) = &self.config.blind_callback {
args.push("--blind".into());
args.push(url.clone());
}
if let Some(rp) = &self.config.remote_payloads {
args.push("--remote-payloads".into());
args.push(rp.clone());
}
if let Some(rw) = &self.config.remote_wordlists {
args.push("--remote-wordlists".into());
args.push(rw.clone());
}
if let Some(val) = &self.config.custom_alert_value {
args.push("--custom-alert-value".into());
args.push(val.clone());
}
if let Some(poc_filter) = &self.config.only_poc {
args.push("--only-poc".into());
args.push(poc_filter.clone());
}
if let Some(pt) = &self.config.poc_type {
args.push("--poc-type".into());
args.push(pt.clone());
}
if let Some(out) = &self.config.output_file {
args.push("--output".into());
args.push(out.clone());
}
for header in &self.config.custom_headers {
args.push("--headers".into());
args.push(header.clone());
}
for payload in &self.config.payloads {
args.push("--custom-payload".into());
args.push(payload.clone());
}
}
fn build_scan_args(&self, format: DalfoxOutputFormat) -> Vec<String> {
let mut args = vec!["scan".to_string()];
self.append_common_args(&mut args, format);
args
}
fn build_file_args(&self, format: DalfoxOutputFormat) -> Vec<String> {
let mut args = vec!["file".to_string()];
self.append_common_args(&mut args, format);
args
}
fn build_pipe_args(&self, format: DalfoxOutputFormat) -> Vec<String> {
let mut args = vec!["pipe".to_string()];
self.append_common_args(&mut args, format);
args
}
fn command_from_args(&self, args: &[String]) -> Command {
let mut cmd = Command::new(self.binary_path());
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.kill_on_drop(true);
cmd
}
fn build_scan_command(&self, format: DalfoxOutputFormat) -> Command {
self.command_from_args(&self.build_scan_args(format))
}
fn build_file_command(&self, format: DalfoxOutputFormat) -> Command {
self.command_from_args(&self.build_file_args(format))
}
fn build_pipe_command(&self, format: DalfoxOutputFormat) -> Command {
self.command_from_args(&self.build_pipe_args(format))
}
async fn parse_json_document(
&self,
mut child: tokio::process::Child,
) -> Result<DalfoxResult, DalfoxError> {
let start = std::time::Instant::now();
let stdout = child.stdout.take().ok_or_else(|| {
DalfoxError::ExecutionFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"failed to capture stdout from dalfox process",
))
})?;
let stderr_handle = child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
if let Some(stderr) = stderr_handle {
let mut reader = BufReader::new(stderr);
let _ = tokio::io::AsyncReadExt::read_to_string(&mut reader, &mut buf).await;
}
buf
});
let mut stdout_buf = String::new();
let mut reader = BufReader::new(stdout);
reader
.read_to_string(&mut stdout_buf)
.await
.map_err(DalfoxError::ExecutionFailed)?;
let mut findings = Vec::new();
let mut parse_errors = Vec::new();
let mut meta = None;
let trimmed = stdout_buf.trim();
if !trimmed.is_empty() {
match serde_json::from_str::<DalfoxJsonEnvelope>(trimmed) {
Ok(envelope) => {
findings = envelope.findings;
meta = envelope.meta;
}
Err(err) => {
tracing::warn!(
output = %trimmed,
error = %err,
"failed to parse dalfox JSON envelope"
);
parse_errors.push(format!("{err}: {trimmed}"));
}
}
}
let status = child.wait().await?;
let stderr_output = stderr_task.await.unwrap_or_default();
let exit_code = status.code();
if !status.success() && findings.is_empty() {
return Err(DalfoxError::ProcessFailed {
status: exit_code.unwrap_or(-1),
stderr: stderr_output,
});
}
Ok(DalfoxResult {
findings,
parse_errors,
stderr_output,
exit_code,
scan_duration: Some(start.elapsed()),
meta,
})
}
async fn parse_jsonl_stream<F>(
&self,
mut child: tokio::process::Child,
mut on_finding: Option<F>,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let start = std::time::Instant::now();
let stdout = child.stdout.take().ok_or_else(|| {
DalfoxError::ExecutionFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"failed to capture stdout from dalfox process",
))
})?;
let stderr_handle = child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
if let Some(stderr) = stderr_handle {
let mut reader = BufReader::new(stderr);
let _ = tokio::io::AsyncReadExt::read_to_string(&mut reader, &mut buf).await;
}
buf
});
let mut reader = BufReader::new(stdout).lines();
let mut findings = Vec::new();
let mut parse_errors = Vec::new();
loop {
match reader.next_line().await {
Ok(Some(line)) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !trimmed.starts_with('{') {
continue;
}
match serde_json::from_str::<DalfoxFinding>(trimmed) {
Ok(finding) => {
if let Some(ref mut cb) = on_finding {
cb(&finding);
}
findings.push(finding);
}
Err(_) => {
if serde_json::from_str::<DalfoxJsonEnvelope>(trimmed).is_ok() {
continue;
}
tracing::warn!(
line = %trimmed,
"failed to parse dalfox JSONL finding line"
);
parse_errors.push(format!("unrecognized jsonl line: {trimmed}"));
}
}
}
Ok(None) => break,
Err(err) => return Err(DalfoxError::ExecutionFailed(err)),
}
}
let status = child.wait().await?;
let stderr_output = stderr_task.await.unwrap_or_default();
let exit_code = status.code();
if !status.success() && findings.is_empty() {
return Err(DalfoxError::ProcessFailed {
status: exit_code.unwrap_or(-1),
stderr: stderr_output,
});
}
Ok(DalfoxResult {
findings,
parse_errors,
stderr_output,
exit_code,
scan_duration: Some(start.elapsed()),
meta: None,
})
}
async fn execute_scan(
&self,
child: tokio::process::Child,
) -> Result<DalfoxResult, DalfoxError> {
if let Some(deadline_secs) = self.config.scan_deadline_secs {
let deadline = std::time::Duration::from_secs(deadline_secs);
match tokio::time::timeout(deadline, self.parse_json_document(child)).await {
Ok(result) => result,
Err(_) => Err(DalfoxError::ScanDeadlineExceeded { deadline_secs }),
}
} else {
self.parse_json_document(child).await
}
}
async fn execute_scan_streaming<F>(
&self,
child: tokio::process::Child,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
if let Some(deadline_secs) = self.config.scan_deadline_secs {
let deadline = std::time::Duration::from_secs(deadline_secs);
match tokio::time::timeout(
deadline,
self.parse_jsonl_stream(child, Some(on_finding)),
)
.await
{
Ok(result) => result,
Err(_) => Err(DalfoxError::ScanDeadlineExceeded { deadline_secs }),
}
} else {
self.parse_jsonl_stream(child, Some(on_finding)).await
}
}
pub async fn scan_url(&self, target_url: &str) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Json);
cmd.arg(target_url);
self.execute_scan(self.map_spawn_result(cmd.spawn())?).await
}
pub async fn scan_url_streaming<F>(
&self,
target_url: &str,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Jsonl);
cmd.arg(target_url);
self.execute_scan_streaming(self.map_spawn_result(cmd.spawn())?, on_finding)
.await
}
pub async fn scan_file_raw(&self, file_path: &str) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_file_command(DalfoxOutputFormat::Json);
cmd.arg(file_path);
self.execute_scan(self.map_spawn_result(cmd.spawn())?).await
}
pub async fn scan_file_raw_streaming<F>(
&self,
file_path: &str,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_file_command(DalfoxOutputFormat::Jsonl);
cmd.arg(file_path);
self.execute_scan_streaming(self.map_spawn_result(cmd.spawn())?, on_finding)
.await
}
pub async fn scan_pipe(&self, urls: Vec<String>) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_pipe_command(DalfoxOutputFormat::Json);
cmd.stdin(Stdio::piped());
let mut child = self.map_spawn_result(cmd.spawn())?;
if let Some(mut stdin) = child.stdin.take() {
let stream_data = urls.join("\n") + "\n";
stdin.write_all(stream_data.as_bytes()).await?;
stdin.flush().await?;
}
self.execute_scan(child).await
}
pub async fn scan_pipe_streaming<F>(
&self,
urls: Vec<String>,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_pipe_command(DalfoxOutputFormat::Jsonl);
cmd.stdin(Stdio::piped());
let mut child = self.map_spawn_result(cmd.spawn())?;
if let Some(mut stdin) = child.stdin.take() {
let stream_data = urls.join("\n") + "\n";
stdin.write_all(stream_data.as_bytes()).await?;
stdin.flush().await?;
}
self.execute_scan_streaming(child, on_finding).await
}
pub async fn scan_sxss(
&self,
inject_url: &str,
trigger_url: &str,
) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Json);
cmd.arg("--sxss")
.arg("--sxss-url")
.arg(trigger_url)
.arg(inject_url);
self.execute_scan(self.map_spawn_result(cmd.spawn())?).await
}
pub async fn scan_sxss_streaming<F>(
&self,
inject_url: &str,
trigger_url: &str,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Jsonl);
cmd.arg("--sxss")
.arg("--sxss-url")
.arg(trigger_url)
.arg(inject_url);
self.execute_scan_streaming(self.map_spawn_result(cmd.spawn())?, on_finding)
.await
}
#[cfg(test)]
pub(crate) fn scan_url_argv_for_test(&self, target_url: &str) -> Vec<String> {
let mut args = self.build_scan_args(DalfoxOutputFormat::Json);
args.push(target_url.to_string());
args
}
}
fn map_spawn_error(err: std::io::Error, path: &str) -> DalfoxError {
if err.kind() == std::io::ErrorKind::NotFound {
DalfoxError::BinaryNotFound {
path: path.to_string(),
}
} else {
DalfoxError::ExecutionFailed(err)
}
}
impl DalfoxResult {
pub fn findings_by_severity(&self, severity: &crate::types::Severity) -> Vec<&DalfoxFinding> {
self.findings
.iter()
.filter(|f| &f.severity == severity)
.collect()
}
pub fn findings_by_type(&self, event_type: &crate::types::EventType) -> Vec<&DalfoxFinding> {
self.findings
.iter()
.filter(|f| &f.event_type == event_type)
.collect()
}
pub fn verified_findings(&self) -> Vec<&DalfoxFinding> {
self.findings_by_type(&crate::types::EventType::Verified)
}
pub fn high_severity_findings(&self) -> Vec<&DalfoxFinding> {
self.findings_by_severity(&crate::types::Severity::High)
}
pub fn has_verified_findings(&self) -> bool {
self.findings
.iter()
.any(|f| f.event_type == crate::types::EventType::Verified)
}
pub fn has_parse_errors(&self) -> bool {
!self.parse_errors.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Dalfox;
#[test]
fn builder_creates_runner() {
let runner = Dalfox::builder()
.request_timeout(30)
.scan_deadline(300)
.workers(10)
.cookie("session=abc")
.waf_evasion(true)
.build();
let _ = runner;
}
#[test]
fn builder_with_custom_binary() {
let runner = Dalfox::builder()
.binary_path("/usr/local/bin/dalfox")
.build();
assert_eq!(runner.binary_path(), "/usr/local/bin/dalfox");
}
#[test]
fn builder_with_all_new_flags() {
let runner = Dalfox::builder()
.cookie_from_raw("/path/to/burp.txt")
.only_discovery(true)
.only_custom_payload(true)
.follow_redirects(true)
.debug(true)
.silence(false)
.ignore_return("302,403,404")
.custom_alert_value("document.domain")
.only_poc("g,v")
.poc_type("curl")
.output_file("/tmp/dalfox-out.json")
.build();
let _ = runner;
}
#[test]
fn builder_with_headers_and_payloads() {
let runner = Dalfox::builder()
.header("X-Custom: value")
.header("Authorization: Bearer token")
.payload("<script>alert(1)</script>")
.payload("'\"><img src=x onerror=alert(1)>")
.build();
let _ = runner;
}
#[test]
fn backward_compat_timeout_sets_both() {
let builder = DalfoxBuilder::new().timeout(30);
assert_eq!(builder.request_timeout_secs, Some(30));
assert_eq!(builder.scan_deadline_secs, Some(30));
}
#[test]
fn binary_path_defaults_to_dalfox() {
let runner = Dalfox::builder().build();
assert_eq!(runner.binary_path(), "dalfox");
}
#[test]
fn result_filtering_helpers() {
let result = DalfoxResult::default();
assert!(result.verified_findings().is_empty());
assert!(result.high_severity_findings().is_empty());
assert!(!result.has_verified_findings());
assert!(!result.has_parse_errors());
}
#[test]
fn scan_url_puts_subcommand_before_flags_and_target_last() {
let runner = Dalfox::builder().workers(5).cookie("a=b").build();
let args = runner.scan_url_argv_for_test("http://example.com/?q=1");
let scan_pos = args.iter().position(|a| a == "scan").expect("scan subcommand");
let format_pos = args
.iter()
.position(|a| a == "--format")
.expect("--format flag");
assert!(scan_pos < format_pos);
assert!(args.contains(&"--workers".to_string()));
assert!(args.contains(&"--cookies".to_string()));
assert_eq!(args.last().map(String::as_str), Some("http://example.com/?q=1"));
assert!(!args.iter().any(|a| a == "url"));
}
#[tokio::test]
async fn version_against_live_dalfox_when_available() {
let runner = Dalfox::builder().build();
if !runner.is_available() {
return;
}
let version = runner.version().await.expect("version from live dalfox");
assert!(version.contains("dalfox"));
}
}