Skip to main content

har/analysis/
curl.rs

1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use crate::redact::{redact_body, redact_header_value, redact_url};
4use serde::Serialize;
5
6const MUTATING_METHODS: &[&str] = &["POST", "PUT", "PATCH", "DELETE"];
7const RISKY_KEYWORDS: &[&str] = &[
8    "payment",
9    "pay",
10    "order",
11    "checkout",
12    "charge",
13    "refund",
14    "subscription",
15];
16const BODY_MAX: usize = 4000;
17
18#[derive(Debug, Serialize)]
19pub struct CurlResult {
20    pub commands: Vec<CurlCommand>,
21}
22
23#[derive(Debug, Serialize)]
24pub struct CurlCommand {
25    pub id: String,
26    pub safe: bool,
27    pub label: String,
28    pub command: String,
29}
30
31/// Build a sanitized, safety-labeled curl command for one entry.
32pub fn entry_to_curl(e: &Entry, unsafe_include: bool) -> CurlCommand {
33    let method = e.method.to_ascii_uppercase();
34    let url = redact_url(&e.url, unsafe_include);
35
36    let mut parts = vec![format!("curl -X {method} '{url}'")];
37    for (k, v) in &e.req_headers {
38        if k.starts_with(':') {
39            continue; // skip HTTP/2 pseudo-headers (:method, :path, :authority, :scheme)
40        }
41        let rv = redact_header_value(k, v, unsafe_include);
42        parts.push(format!("  -H '{k}: {rv}'"));
43    }
44    if let Some(body) = e.req_body.as_deref().filter(|b| !b.is_empty()) {
45        let rb = redact_body(body, unsafe_include, BODY_MAX);
46        parts.push(format!("  --data '{rb}'"));
47    }
48
49    let (safe, label) = safety(&method, &e.norm_path);
50    CurlCommand {
51        id: e.id.clone(),
52        safe,
53        label,
54        command: parts.join(" \\\n"),
55    }
56}
57
58/// Render curl for every filtered entry, bounded by `top`.
59pub fn compute_curl(
60    cap: &Capture,
61    filter: &Filter,
62    top: usize,
63    unsafe_include: bool,
64) -> CurlResult {
65    let commands: Vec<CurlCommand> = cap
66        .entries
67        .iter()
68        .filter(|e| filter.matches(e))
69        .take(top)
70        .map(|e| entry_to_curl(e, unsafe_include))
71        .collect();
72    CurlResult { commands }
73}
74
75fn safety(method: &str, norm_path: &str) -> (bool, String) {
76    let lp = norm_path.to_ascii_lowercase();
77    if RISKY_KEYWORDS.iter().any(|k| lp.contains(k)) {
78        return (false, "payment/order endpoint".to_string());
79    }
80    if MUTATING_METHODS.contains(&method) {
81        return (false, format!("mutating method {method}"));
82    }
83    (true, "safe".to_string())
84}
85
86/// Render curl commands as terminal text with safety annotations.
87pub fn render_curl_text(r: &CurlResult) -> String {
88    let mut out = String::new();
89    out.push_str("== wiretrail curl ==\n");
90    for c in &r.commands {
91        let tag = if c.safe { "SAFE" } else { "UNSAFE" };
92        out.push_str(&format!(
93            "\n# {} [{}: {}]\n{}\n",
94            c.id, tag, c.label, c.command
95        ));
96    }
97    out
98}
99
100#[cfg(test)]
101mod tests {
102    use super::{compute_curl, entry_to_curl};
103    use crate::filter::Filter;
104    use crate::model::{sample_capture, sample_entry};
105
106    #[test]
107    fn get_is_safe_and_redacts_auth() {
108        let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
109        e.req_headers = vec![
110            (":method".into(), "GET".into()), // HTTP/2 pseudo-header, must be skipped
111            ("Authorization".into(), "Bearer secret".into()),
112            ("Accept".into(), "application/json".into()),
113        ];
114        let c = entry_to_curl(&e, false);
115        assert!(c.safe);
116        assert_eq!(c.label, "safe");
117        assert!(c.command.starts_with("curl -X GET 'https://api.x/data'"));
118        assert!(c.command.contains("Authorization: <redacted>"));
119        assert!(c.command.contains("Accept: application/json"));
120        assert!(!c.command.contains(":method"));
121    }
122
123    #[test]
124    fn redacts_query_in_url() {
125        let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
126        e.url = "https://api.x/data?access_token=leak&page=2".into();
127        let c = entry_to_curl(&e, false);
128        assert!(!c.command.contains("leak"));
129        assert!(c.command.contains("page=2"));
130    }
131
132    #[test]
133    fn mutating_method_is_unsafe() {
134        let e = sample_entry(0, "api.x", "POST", "/things", 200);
135        let c = entry_to_curl(&e, false);
136        assert!(!c.safe);
137        assert!(c.label.contains("mutating"));
138    }
139
140    #[test]
141    fn payment_path_is_unsafe_even_for_get() {
142        let e = sample_entry(0, "api.x", "GET", "/v1/payment/charge", 200);
143        let c = entry_to_curl(&e, false);
144        assert!(!c.safe);
145        assert!(c.label.contains("payment"));
146    }
147
148    #[test]
149    fn unsafe_flag_shows_raw_auth() {
150        let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
151        e.req_headers = vec![("Authorization".into(), "Bearer secret".into())];
152        let c = entry_to_curl(&e, true);
153        assert!(c.command.contains("Bearer secret"));
154    }
155
156    #[test]
157    fn compute_curl_bounds_by_top() {
158        let cap = sample_capture(vec![
159            sample_entry(0, "h", "GET", "/a", 200),
160            sample_entry(1, "h", "GET", "/b", 200),
161            sample_entry(2, "h", "GET", "/c", 200),
162        ]);
163        let r = compute_curl(&cap, &Filter::parse(&[]).unwrap(), 2, false);
164        assert_eq!(r.commands.len(), 2);
165    }
166}