Skip to main content

dalfox_rs/
types.rs

1//! Strictly-typed structures for Dalfox scan results.
2//!
3//! Every field from Dalfox's JSON output is mapped to a concrete Rust type,
4//! with enums for known-finite value sets and `Other`/`Unknown` fallbacks
5//! for forward compatibility with newer Dalfox versions.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// The classification of a finding event.
11///
12/// Dalfox emits different event types depending on the confidence
13/// and nature of the detection.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum EventType {
17    /// Verified XSS vulnerability with confirmed execution.
18    #[serde(rename = "V")]
19    Verified,
20    /// Grep-based match (information disclosure, sensitive patterns).
21    #[serde(rename = "G")]
22    Grep,
23    /// Informational finding (no direct vulnerability).
24    #[serde(rename = "I")]
25    Information,
26    /// Reflected parameter detected but unverified.
27    #[serde(rename = "R")]
28    Reflected,
29    /// Unknown event type from a newer Dalfox version.
30    #[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/// Vulnerability severity as reported by Dalfox.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
48#[non_exhaustive]
49pub enum Severity {
50    /// High severity (critical/high impact XSS).
51    #[serde(rename = "High")]
52    High,
53    /// Medium severity.
54    #[serde(rename = "Medium")]
55    Medium,
56    /// Low severity.
57    #[serde(rename = "Low")]
58    Low,
59    /// Informational severity.
60    #[serde(rename = "Information", alias = "Info")]
61    Information,
62    /// Unknown severity from a newer Dalfox version.
63    #[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/// HTTP method used in the finding.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
81#[non_exhaustive]
82pub enum Method {
83    /// HTTP GET.
84    #[serde(rename = "GET")]
85    Get,
86    /// HTTP POST.
87    #[serde(rename = "POST")]
88    Post,
89    /// HTTP PUT.
90    #[serde(rename = "PUT")]
91    Put,
92    /// HTTP DELETE.
93    #[serde(rename = "DELETE")]
94    Delete,
95    /// HTTP HEAD.
96    #[serde(rename = "HEAD")]
97    Head,
98    /// HTTP OPTIONS.
99    #[serde(rename = "OPTIONS")]
100    Options,
101    /// HTTP PATCH.
102    #[serde(rename = "PATCH")]
103    Patch,
104    /// Other or custom HTTP method.
105    #[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/// A structured finding reported by Dalfox via its JSON output.
125///
126/// Each finding represents a single detected XSS vector with full
127/// contextual information about the injection point, payload, and evidence.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[non_exhaustive]
130pub struct DalfoxFinding {
131    /// The classification of this finding (verified, grep, info, reflected).
132    #[serde(rename = "type")]
133    pub event_type: EventType,
134
135    /// The proof-of-concept URL demonstrating the vulnerability.
136    pub poc: String,
137
138    /// HTTP method used for the scan request.
139    pub method: Method,
140
141    /// Request body data (populated for POST, PUT, etc).
142    #[serde(default)]
143    pub data: String,
144
145    /// The specific parameter that was injected.
146    pub param: String,
147
148    /// The actual XSS payload used.
149    pub payload: String,
150
151    /// The response snippet verifying payload reflection or execution.
152    #[serde(default)]
153    pub evidence: String,
154
155    /// CWE classification identifier (e.g. "CWE-79").
156    pub cwe: String,
157
158    /// The vulnerability severity rating.
159    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/// Aggregate results from a Dalfox scan execution.
178///
179/// Contains the parsed findings, diagnostic metadata, and any parse errors
180/// encountered while processing Dalfox's output stream.
181#[derive(Debug, Clone, Default)]
182#[non_exhaustive]
183pub struct DalfoxResult {
184    /// The detected XSS findings from the scan.
185    pub findings: Vec<DalfoxFinding>,
186
187    /// Lines from Dalfox output that failed to parse as valid findings.
188    ///
189    /// Non-empty values indicate a potential Dalfox schema change or
190    /// corrupted output. Each entry contains `"parse_error: raw_line"`.
191    pub parse_errors: Vec<String>,
192
193    /// Captured stderr output from the Dalfox process.
194    ///
195    /// Contains warnings, progress information, and diagnostic messages
196    /// emitted by the Dalfox binary during execution.
197    pub stderr_output: String,
198
199    /// The exit code of the Dalfox process, if available.
200    pub exit_code: Option<i32>,
201
202    /// Wall-clock duration of the scan.
203    pub scan_duration: Option<std::time::Duration>,
204}
205
206/// Supported output formats for scan results.
207///
208/// Use with [`DalfoxResult::format_as`] to convert findings into
209/// the desired output representation.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
211#[non_exhaustive]
212pub enum OutputFormat {
213    /// Compact single-line JSON array.
214    Json,
215    /// Pretty-printed JSON array.
216    JsonPretty,
217    /// Comma-separated values with header row.
218    Csv,
219    /// GitHub-flavored Markdown table.
220    Markdown,
221    /// Human-readable plain text report.
222    Plain,
223}
224
225impl DalfoxResult {
226    /// Format the scan results in the specified output format.
227    ///
228    /// # Examples
229    ///
230    /// ```rust
231    /// # use dalfox_rs::types::{DalfoxResult, OutputFormat};
232    /// let result = DalfoxResult::default();
233    /// let csv = result.format_as(OutputFormat::Csv);
234    /// assert!(csv.starts_with("severity,"));
235    /// ```
236    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        // Vec<DalfoxFinding> serialization is infallible for well-formed types,
253        // but we handle the impossible case gracefully.
254        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
337/// Escape a value for CSV output.
338fn 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
346/// Escape pipe and backtick characters for Markdown table cells.
347fn 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}