1use serde::{Deserialize, Serialize};
8use std::fmt;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum EventType {
17 #[serde(rename = "V")]
19 Verified,
20 #[serde(rename = "G")]
22 Grep,
23 #[serde(rename = "I")]
25 Information,
26 #[serde(rename = "R")]
28 Reflected,
29 #[serde(untagged)]
31 Other(String),
32}
33
34impl fmt::Display for EventType {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 Self::Verified => write!(f, "Verified"),
38 Self::Grep => write!(f, "Grep"),
39 Self::Information => write!(f, "Info"),
40 Self::Reflected => write!(f, "Reflected"),
41 Self::Other(s) => write!(f, "{s}"),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
48#[non_exhaustive]
49pub enum Severity {
50 #[serde(rename = "High")]
52 High,
53 #[serde(rename = "Medium")]
55 Medium,
56 #[serde(rename = "Low")]
58 Low,
59 #[serde(rename = "Information", alias = "Info")]
61 Information,
62 #[serde(untagged)]
64 Unknown(String),
65}
66
67impl fmt::Display for Severity {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::High => write!(f, "High"),
71 Self::Medium => write!(f, "Medium"),
72 Self::Low => write!(f, "Low"),
73 Self::Information => write!(f, "Info"),
74 Self::Unknown(s) => write!(f, "{s}"),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
81#[non_exhaustive]
82pub enum Method {
83 #[serde(rename = "GET")]
85 Get,
86 #[serde(rename = "POST")]
88 Post,
89 #[serde(rename = "PUT")]
91 Put,
92 #[serde(rename = "DELETE")]
94 Delete,
95 #[serde(rename = "HEAD")]
97 Head,
98 #[serde(rename = "OPTIONS")]
100 Options,
101 #[serde(rename = "PATCH")]
103 Patch,
104 #[serde(untagged)]
106 Other(String),
107}
108
109impl fmt::Display for Method {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 Self::Get => write!(f, "GET"),
113 Self::Post => write!(f, "POST"),
114 Self::Put => write!(f, "PUT"),
115 Self::Delete => write!(f, "DELETE"),
116 Self::Head => write!(f, "HEAD"),
117 Self::Options => write!(f, "OPTIONS"),
118 Self::Patch => write!(f, "PATCH"),
119 Self::Other(s) => write!(f, "{s}"),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
129#[non_exhaustive]
130pub struct DalfoxFinding {
131 #[serde(rename = "type")]
133 pub event_type: EventType,
134
135 pub poc: String,
137
138 pub method: Method,
140
141 #[serde(default)]
143 pub data: String,
144
145 pub param: String,
147
148 pub payload: String,
150
151 #[serde(default)]
153 pub evidence: String,
154
155 pub cwe: String,
157
158 pub severity: Severity,
160}
161
162impl fmt::Display for DalfoxFinding {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 write!(
165 f,
166 "[{sev}][{evt}] {cwe} on param '{param}' via {method} — {poc}",
167 sev = self.severity,
168 evt = self.event_type,
169 cwe = self.cwe,
170 param = self.param,
171 method = self.method,
172 poc = self.poc,
173 )
174 }
175}
176
177#[derive(Debug, Clone, Default)]
182#[non_exhaustive]
183pub struct DalfoxResult {
184 pub findings: Vec<DalfoxFinding>,
186
187 pub parse_errors: Vec<String>,
192
193 pub stderr_output: String,
198
199 pub exit_code: Option<i32>,
201
202 pub scan_duration: Option<std::time::Duration>,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
211#[non_exhaustive]
212pub enum OutputFormat {
213 Json,
215 JsonPretty,
217 Csv,
219 Markdown,
221 Plain,
223}
224
225impl DalfoxResult {
226 pub fn format_as(&self, format: OutputFormat) -> String {
237 match format {
238 OutputFormat::Json => self.format_json(false),
239 OutputFormat::JsonPretty => self.format_json(true),
240 OutputFormat::Csv => self.format_csv(),
241 OutputFormat::Markdown => self.format_markdown(),
242 OutputFormat::Plain => self.format_plain(),
243 }
244 }
245
246 fn format_json(&self, pretty: bool) -> String {
247 let result = if pretty {
248 serde_json::to_string_pretty(&self.findings)
249 } else {
250 serde_json::to_string(&self.findings)
251 };
252 match result {
255 Ok(json) => json,
256 Err(err) => format!("{{\"error\": \"serialization failed: {err}\"}}"),
257 }
258 }
259
260 fn format_csv(&self) -> String {
261 let mut buf = String::from("severity,type,method,param,cwe,poc,payload,evidence\n");
262 for finding in &self.findings {
263 buf.push_str(&format!(
264 "{},{},{},{},{},{},{},{}\n",
265 csv_escape(&finding.severity.to_string()),
266 csv_escape(&finding.event_type.to_string()),
267 csv_escape(&finding.method.to_string()),
268 csv_escape(&finding.param),
269 csv_escape(&finding.cwe),
270 csv_escape(&finding.poc),
271 csv_escape(&finding.payload),
272 csv_escape(&finding.evidence),
273 ));
274 }
275 buf
276 }
277
278 fn format_markdown(&self) -> String {
279 if self.findings.is_empty() {
280 return "No findings.\n".to_string();
281 }
282 let mut buf =
283 String::from("| Severity | Type | Method | Param | CWE | PoC | Payload |\n");
284 buf.push_str("|----------|------|--------|-------|-----|-----|----------|\n");
285 for finding in &self.findings {
286 buf.push_str(&format!(
287 "| {} | {} | {} | `{}` | {} | [link]({}) | `{}` |\n",
288 finding.severity,
289 finding.event_type,
290 finding.method,
291 finding.param,
292 finding.cwe,
293 finding.poc,
294 md_escape(&finding.payload),
295 ));
296 }
297 buf
298 }
299
300 fn format_plain(&self) -> String {
301 if self.findings.is_empty() {
302 return "No XSS findings detected.\n".to_string();
303 }
304 let mut buf = format!("=== {} Finding(s) ===\n\n", self.findings.len());
305 for (i, finding) in self.findings.iter().enumerate() {
306 let evidence_display = if finding.evidence.is_empty() {
307 "(none)"
308 } else {
309 &finding.evidence
310 };
311 buf.push_str(&format!(
312 "#{} [{}] {} ({})\n Parameter: {}\n Method: {}\n PoC: {}\n Payload: {}\n Evidence: {}\n\n",
313 i + 1,
314 finding.severity,
315 finding.cwe,
316 finding.event_type,
317 finding.param,
318 finding.method,
319 finding.poc,
320 finding.payload,
321 evidence_display,
322 ));
323 }
324 if !self.parse_errors.is_empty() {
325 buf.push_str(&format!(
326 "--- {} Parse Error(s) ---\n",
327 self.parse_errors.len()
328 ));
329 for err in &self.parse_errors {
330 buf.push_str(&format!(" • {err}\n"));
331 }
332 }
333 buf
334 }
335}
336
337fn csv_escape(value: &str) -> String {
339 if value.contains(',') || value.contains('"') || value.contains('\n') {
340 format!("\"{}\"", value.replace('"', "\"\""))
341 } else {
342 value.to_string()
343 }
344}
345
346fn md_escape(value: &str) -> String {
348 value.replace('|', "\\|").replace('`', "\\`")
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn finding_deserialize() {
357 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"}"#;
358 let finding: DalfoxFinding = serde_json::from_str(json).expect("valid finding JSON");
359 assert_eq!(finding.event_type, EventType::Verified);
360 assert_eq!(finding.param, "q");
361 assert_eq!(finding.severity, Severity::High);
362 assert_eq!(finding.cwe, "CWE-79");
363 assert_eq!(finding.method, Method::Get);
364 }
365
366 #[test]
367 fn event_type_variants() {
368 assert_eq!(
369 serde_json::from_str::<EventType>("\"V\"").expect("verified"),
370 EventType::Verified
371 );
372 assert_eq!(
373 serde_json::from_str::<EventType>("\"G\"").expect("grep"),
374 EventType::Grep
375 );
376 assert_eq!(
377 serde_json::from_str::<EventType>("\"I\"").expect("info"),
378 EventType::Information
379 );
380 assert_eq!(
381 serde_json::from_str::<EventType>("\"R\"").expect("reflected"),
382 EventType::Reflected
383 );
384 assert_eq!(
385 serde_json::from_str::<EventType>("\"XNEW\"").expect("unknown"),
386 EventType::Other("XNEW".to_string())
387 );
388 }
389
390 #[test]
391 fn severity_aliases() {
392 assert_eq!(
393 serde_json::from_str::<Severity>("\"Info\"").expect("info alias"),
394 Severity::Information
395 );
396 assert_eq!(
397 serde_json::from_str::<Severity>("\"Information\"").expect("info full"),
398 Severity::Information
399 );
400 assert_eq!(
401 serde_json::from_str::<Severity>("\"POTENTIAL\"").expect("unknown"),
402 Severity::Unknown("POTENTIAL".to_string())
403 );
404 }
405
406 #[test]
407 fn method_patch_variant() {
408 assert_eq!(
409 serde_json::from_str::<Method>("\"PATCH\"").expect("patch"),
410 Method::Patch
411 );
412 assert_eq!(
413 serde_json::from_str::<Method>("\"CUSTOM\"").expect("custom"),
414 Method::Other("CUSTOM".to_string())
415 );
416 }
417
418 #[test]
419 fn result_default_is_empty() {
420 let result = DalfoxResult::default();
421 assert!(result.findings.is_empty());
422 assert!(result.parse_errors.is_empty());
423 assert!(result.stderr_output.is_empty());
424 assert!(result.exit_code.is_none());
425 assert!(result.scan_duration.is_none());
426 }
427
428 #[test]
429 fn format_csv_header() {
430 let result = DalfoxResult::default();
431 let csv = result.format_as(OutputFormat::Csv);
432 assert!(csv.starts_with("severity,type,method,param,cwe,poc,payload,evidence\n"));
433 }
434
435 #[test]
436 fn format_plain_empty() {
437 let result = DalfoxResult::default();
438 let plain = result.format_as(OutputFormat::Plain);
439 assert_eq!(plain, "No XSS findings detected.\n");
440 }
441
442 #[test]
443 fn format_markdown_empty() {
444 let result = DalfoxResult::default();
445 let md = result.format_as(OutputFormat::Markdown);
446 assert_eq!(md, "No findings.\n");
447 }
448
449 #[test]
450 fn csv_escape_handles_commas_and_quotes() {
451 assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
452 assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
453 assert_eq!(csv_escape("simple"), "simple");
454 }
455
456 #[test]
457 fn display_impls_are_readable() {
458 assert_eq!(EventType::Verified.to_string(), "Verified");
459 assert_eq!(Severity::High.to_string(), "High");
460 assert_eq!(Method::Get.to_string(), "GET");
461 }
462}