use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum EventType {
#[serde(rename = "V")]
Verified,
#[serde(rename = "G")]
Grep,
#[serde(rename = "I")]
Information,
#[serde(rename = "R")]
Reflected,
#[serde(rename = "A")]
Ast,
#[serde(untagged)]
Other(String),
}
impl fmt::Display for EventType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Verified => write!(f, "Verified"),
Self::Grep => write!(f, "Grep"),
Self::Information => write!(f, "Info"),
Self::Reflected => write!(f, "Reflected"),
Self::Ast => write!(f, "AST"),
Self::Other(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Severity {
#[serde(rename = "High")]
High,
#[serde(rename = "Medium")]
Medium,
#[serde(rename = "Low")]
Low,
#[serde(rename = "Information", alias = "Info")]
Information,
#[serde(untagged)]
Unknown(String),
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::High => write!(f, "High"),
Self::Medium => write!(f, "Medium"),
Self::Low => write!(f, "Low"),
Self::Information => write!(f, "Info"),
Self::Unknown(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Method {
#[serde(rename = "GET")]
Get,
#[serde(rename = "POST")]
Post,
#[serde(rename = "PUT")]
Put,
#[serde(rename = "DELETE")]
Delete,
#[serde(rename = "HEAD")]
Head,
#[serde(rename = "OPTIONS")]
Options,
#[serde(rename = "PATCH")]
Patch,
#[serde(untagged)]
Other(String),
}
impl fmt::Display for Method {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Get => write!(f, "GET"),
Self::Post => write!(f, "POST"),
Self::Put => write!(f, "PUT"),
Self::Delete => write!(f, "DELETE"),
Self::Head => write!(f, "HEAD"),
Self::Options => write!(f, "OPTIONS"),
Self::Patch => write!(f, "PATCH"),
Self::Other(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DalfoxFinding {
#[serde(rename = "type")]
pub event_type: EventType,
#[serde(default)]
pub poc: String,
pub method: Method,
#[serde(default)]
pub data: String,
pub param: String,
pub payload: String,
#[serde(default)]
pub evidence: String,
pub cwe: String,
pub severity: Severity,
#[serde(default)]
pub inject_type: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub message_str: Option<String>,
#[serde(default)]
pub type_description: Option<String>,
}
impl DalfoxFinding {
pub fn poc_url(&self) -> &str {
if self.poc.is_empty() {
&self.data
} else {
&self.poc
}
}
}
impl fmt::Display for DalfoxFinding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[{sev}][{evt}] {cwe} on param '{param}' via {method} — {poc}",
sev = self.severity,
evt = self.event_type,
cwe = self.cwe,
param = self.param,
method = self.method,
poc = self.poc_url(),
)
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct DalfoxJsonEnvelope {
#[serde(default)]
pub findings: Vec<DalfoxFinding>,
#[serde(default)]
pub meta: Option<serde_json::Value>,
#[serde(default)]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DalfoxResult {
pub findings: Vec<DalfoxFinding>,
pub parse_errors: Vec<String>,
pub stderr_output: String,
pub exit_code: Option<i32>,
pub scan_duration: Option<std::time::Duration>,
pub meta: Option<serde_json::Value>,
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutputFormat {
Json,
JsonPretty,
Csv,
Markdown,
Plain,
}
impl DalfoxResult {
pub fn format_as(&self, format: OutputFormat) -> String {
match format {
OutputFormat::Json => self.format_json(false),
OutputFormat::JsonPretty => self.format_json(true),
OutputFormat::Csv => self.format_csv(),
OutputFormat::Markdown => self.format_markdown(),
OutputFormat::Plain => self.format_plain(),
}
}
fn format_json(&self, pretty: bool) -> String {
let result = if pretty {
serde_json::to_string_pretty(&self.findings)
} else {
serde_json::to_string(&self.findings)
};
match result {
Ok(json) => json,
Err(err) => format!("{{\"error\": \"serialization failed: {err}\"}}"),
}
}
fn format_csv(&self) -> String {
let mut buf = String::from(
"severity,type,method,param,cwe,poc,payload,evidence,inject_type,location,message_str,type_description\n",
);
for finding in &self.findings {
buf.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
csv_escape(&finding.severity.to_string()),
csv_escape(&finding.event_type.to_string()),
csv_escape(&finding.method.to_string()),
csv_escape(&finding.param),
csv_escape(&finding.cwe),
csv_escape(finding.poc_url()),
csv_escape(&finding.payload),
csv_escape(&finding.evidence),
csv_escape(opt_str(&finding.inject_type)),
csv_escape(opt_str(&finding.location)),
csv_escape(opt_str(&finding.message_str)),
csv_escape(opt_str(&finding.type_description)),
));
}
buf
}
fn format_markdown(&self) -> String {
if self.findings.is_empty() {
return "No findings.\n".to_string();
}
let mut buf = String::from(
"| Severity | Type | Method | Param | CWE | PoC | Payload | Inject | Location | Message | Description |\n",
);
buf.push_str("|----------|------|--------|-------|-----|-----|---------|--------|----------|---------|-------------|\n");
for finding in &self.findings {
buf.push_str(&format!(
"| {} | {} | {} | `{}` | {} | [link]({}) | `{}` | {} | {} | {} | {} |\n",
finding.severity,
finding.event_type,
finding.method,
finding.param,
finding.cwe,
md_link_target(finding.poc_url()),
md_escape(&finding.payload),
md_escape(opt_str(&finding.inject_type)),
md_escape(opt_str(&finding.location)),
md_escape(opt_str(&finding.message_str)),
md_escape(opt_str(&finding.type_description)),
));
}
buf
}
fn format_plain(&self) -> String {
if self.findings.is_empty() {
return "No XSS findings detected.\n".to_string();
}
let mut buf = format!("=== {} Finding(s) ===\n\n", self.findings.len());
for (i, finding) in self.findings.iter().enumerate() {
let evidence_display = if finding.evidence.is_empty() {
"(none)"
} else {
&finding.evidence
};
buf.push_str(&format!(
"#{} [{}] {} ({})\n Parameter: {}\n Method: {}\n PoC: {}\n Payload: {}\n Evidence: {}\n",
i + 1,
finding.severity,
finding.cwe,
finding.event_type,
finding.param,
finding.method,
finding.poc_url(),
finding.payload,
evidence_display,
));
if let Some(inject_type) = &finding.inject_type {
buf.push_str(&format!(" Inject type: {inject_type}\n"));
}
if let Some(location) = &finding.location {
buf.push_str(&format!(" Location: {location}\n"));
}
if let Some(message) = &finding.message_str {
buf.push_str(&format!(" Message: {message}\n"));
}
if let Some(desc) = &finding.type_description {
buf.push_str(&format!(" Description: {desc}\n"));
}
buf.push('\n');
}
if !self.parse_errors.is_empty() {
buf.push_str(&format!(
"--- {} Parse Error(s) ---\n",
self.parse_errors.len()
));
for err in &self.parse_errors {
buf.push_str(&format!(" • {err}\n"));
}
}
buf
}
}
fn csv_escape(value: &str) -> String {
if value.contains(',') || value.contains('"') || value.contains('\n') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn opt_str(opt: &Option<String>) -> &str {
opt.as_deref().unwrap_or("")
}
fn md_escape(value: &str) -> String {
value.replace('|', "\\|").replace('`', "\\`")
}
fn md_link_target(url: &str) -> String {
if url.contains(')') || url.contains('(') || url.contains(' ') {
let escaped = url.replace('<', "%3C").replace('>', "%3E");
format!("<{escaped}>")
} else {
url.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finding_deserialize_v3_without_poc() {
let json = include_str!("../tests/fixtures/v3_verified_finding.json");
let finding: DalfoxFinding = serde_json::from_str(json).expect("v3 finding JSON");
assert_eq!(finding.event_type, EventType::Verified);
assert_eq!(finding.poc, "");
assert!(!finding.data.is_empty());
assert_eq!(finding.poc_url(), finding.data.as_str());
}
#[test]
fn finding_deserialize() {
let json = r#"{"type":"V","poc":"http://example.com?q=%3Cscript%3Ealert(1)%3C/script%3E","method":"GET","data":"","param":"q","payload":"<script>alert(1)</script>","evidence":"<script>alert(1)</script>","cwe":"CWE-79","severity":"High"}"#;
let finding: DalfoxFinding = serde_json::from_str(json).expect("valid finding JSON");
assert_eq!(finding.event_type, EventType::Verified);
assert_eq!(finding.param, "q");
assert_eq!(finding.severity, Severity::High);
assert_eq!(finding.cwe, "CWE-79");
assert_eq!(finding.method, Method::Get);
}
#[test]
fn event_type_variants() {
assert_eq!(
serde_json::from_str::<EventType>("\"V\"").expect("verified"),
EventType::Verified
);
assert_eq!(
serde_json::from_str::<EventType>("\"G\"").expect("grep"),
EventType::Grep
);
assert_eq!(
serde_json::from_str::<EventType>("\"I\"").expect("info"),
EventType::Information
);
assert_eq!(
serde_json::from_str::<EventType>("\"R\"").expect("reflected"),
EventType::Reflected
);
assert_eq!(
serde_json::from_str::<EventType>("\"XNEW\"").expect("unknown"),
EventType::Other("XNEW".to_string())
);
}
#[test]
fn severity_aliases() {
assert_eq!(
serde_json::from_str::<Severity>("\"Info\"").expect("info alias"),
Severity::Information
);
assert_eq!(
serde_json::from_str::<Severity>("\"Information\"").expect("info full"),
Severity::Information
);
assert_eq!(
serde_json::from_str::<Severity>("\"POTENTIAL\"").expect("unknown"),
Severity::Unknown("POTENTIAL".to_string())
);
}
#[test]
fn method_patch_variant() {
assert_eq!(
serde_json::from_str::<Method>("\"PATCH\"").expect("patch"),
Method::Patch
);
assert_eq!(
serde_json::from_str::<Method>("\"CUSTOM\"").expect("custom"),
Method::Other("CUSTOM".to_string())
);
}
#[test]
fn result_default_is_empty() {
let result = DalfoxResult::default();
assert!(result.findings.is_empty());
assert!(result.parse_errors.is_empty());
assert!(result.stderr_output.is_empty());
assert!(result.exit_code.is_none());
assert!(result.scan_duration.is_none());
assert!(result.meta.is_none());
assert!(result.params.is_none());
}
#[test]
fn json_envelope_deserialize_with_meta() {
let json = r#"{"findings":[],"meta":{"dalfox_version":"3.1.2","targets_input":1}}"#;
let envelope: DalfoxJsonEnvelope =
serde_json::from_str(json).expect("valid v3 JSON envelope");
assert!(envelope.findings.is_empty());
let meta = envelope.meta.expect("meta present");
assert_eq!(meta["dalfox_version"], "3.1.2");
}
#[test]
fn json_envelope_deserialize_discovery_params() {
let json = r#"{"meta":{"dalfox_version":"3.1.2"},"params":[{"name":"q"}]}"#;
let envelope: DalfoxJsonEnvelope =
serde_json::from_str(json).expect("valid discovery envelope");
assert!(envelope.findings.is_empty());
let params = envelope.params.expect("params present");
let arr = params.as_array().expect("params array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], "q");
}
#[test]
fn json_envelope_deserialize_findings_and_meta() {
let json = r#"{
"findings":[{"type":"V","poc":"http://example.com","method":"GET","param":"q","payload":"x","cwe":"CWE-79","severity":"High"}],
"meta":{"dalfox_version":"3.1.2"}
}"#;
let envelope: DalfoxJsonEnvelope =
serde_json::from_str(json).expect("valid envelope with findings");
assert_eq!(envelope.findings.len(), 1);
assert_eq!(envelope.findings[0].event_type, EventType::Verified);
}
#[test]
fn event_type_ast_variant() {
assert_eq!(
serde_json::from_str::<EventType>("\"A\"").expect("ast"),
EventType::Ast
);
assert_eq!(EventType::Ast.to_string(), "AST");
}
#[test]
fn format_csv_header() {
let result = DalfoxResult::default();
let csv = result.format_as(OutputFormat::Csv);
assert!(csv.starts_with(
"severity,type,method,param,cwe,poc,payload,evidence,inject_type,location,message_str,type_description\n"
));
}
#[test]
fn format_csv_includes_v3_fields() {
let json = include_str!("../tests/fixtures/v3_verified_finding.json");
let finding: DalfoxFinding = serde_json::from_str(json).expect("v3 finding");
let result = DalfoxResult {
findings: vec![finding],
..Default::default()
};
let csv = result.format_as(OutputFormat::Csv);
assert!(csv.contains("inHTML"));
assert!(csv.contains("Query"));
assert!(csv.contains("Triggered XSS Payload"));
assert!(csv.contains("Verified XSS - payload confirmed"));
}
#[test]
fn format_markdown_poc_url_with_paren_not_truncated() {
let finding = DalfoxFinding {
event_type: EventType::Verified,
poc: String::new(),
method: Method::Get,
data: "http://example.com/?q=alert(1)".to_string(),
param: "q".to_string(),
payload: "x".to_string(),
evidence: String::new(),
cwe: "CWE-79".to_string(),
severity: Severity::High,
inject_type: None,
location: None,
message_str: None,
type_description: None,
};
let result = DalfoxResult {
findings: vec![finding],
..Default::default()
};
let md = result.format_as(OutputFormat::Markdown);
assert!(
md.contains("[link](<http://example.com/?q=alert(1)>)"),
"PoC URL with ) must use angle-bracket link target, got: {md}"
);
assert!(
!md.contains("[link](http://example.com/?q=alert(1)"),
"unescaped ) must not terminate the Markdown link early: {md}"
);
}
#[test]
fn md_link_target_escapes_angle_brackets_in_parens_url() {
assert_eq!(md_link_target("http://x/?a=<b>"), "http://x/?a=<b>");
assert_eq!(md_link_target("http://x/?a=(1)"), "<http://x/?a=(1)>");
}
#[test]
fn format_plain_empty() {
let result = DalfoxResult::default();
let plain = result.format_as(OutputFormat::Plain);
assert_eq!(plain, "No XSS findings detected.\n");
}
#[test]
fn format_markdown_empty() {
let result = DalfoxResult::default();
let md = result.format_as(OutputFormat::Markdown);
assert_eq!(md, "No findings.\n");
}
#[test]
fn csv_escape_handles_commas_and_quotes() {
assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
assert_eq!(csv_escape("simple"), "simple");
}
#[test]
fn display_impls_are_readable() {
assert_eq!(EventType::Verified.to_string(), "Verified");
assert_eq!(Severity::High.to_string(), "High");
assert_eq!(Method::Get.to_string(), "GET");
}
}