Skip to main content

jsdet_cli/
ioc.rs

1/// IOC (Indicator of Compromise) extraction from observations.
2///
3/// After detonation, the researcher wants:
4/// - What C2 URLs were contacted?
5/// - What data was exfiltrated?
6/// - What decoded payloads were executed?
7/// - What credential harvesting infrastructure was set up?
8/// - What persistence mechanisms were installed?
9///
10/// This module extracts all of that from raw observations automatically.
11///
12use std::collections::HashSet;
13
14use jsdet_core::Observation;
15
16/// Extracted IOCs from a detonation.
17#[derive(Debug, Default)]
18pub struct IocReport {
19    /// C2/exfiltration URLs contacted.
20    pub c2_urls: Vec<C2Url>,
21    /// Decoded payloads from eval(atob(...)) chains.
22    pub decoded_payloads: Vec<DecodedPayload>,
23    /// Credential harvesting infrastructure.
24    pub credential_forms: Vec<CredentialForm>,
25    /// Data exfiltrated (cookies, UA, etc.).
26    pub exfiltrated_data: Vec<ExfilData>,
27    /// Persistence mechanisms.
28    pub persistence: Vec<Persistence>,
29    /// Redirect chain.
30    pub redirects: Vec<String>,
31    /// All external domains referenced.
32    pub domains: Vec<String>,
33    /// Crypto wallet interactions.
34    pub crypto_iocs: Vec<CryptoIoc>,
35    /// Clipboard manipulation.
36    pub clipboard_iocs: Vec<ClipboardIoc>,
37}
38
39#[derive(Debug)]
40#[allow(dead_code)]
41pub struct CryptoIoc {
42    pub chain: String,
43    pub method: String,
44    pub addresses: Vec<String>,
45    pub signing_method: Option<String>,
46    pub observation_index: usize,
47}
48
49#[derive(Debug)]
50#[allow(dead_code)]
51pub struct ClipboardIoc {
52    pub operation: String,
53    pub content: Option<String>,
54    pub observation_index: usize,
55}
56
57#[derive(Debug)]
58#[allow(dead_code)]
59pub struct C2Url {
60    pub url: String,
61    pub method: String,
62    pub contains_encoded_data: bool,
63    pub observation_index: usize,
64}
65
66#[derive(Debug)]
67#[allow(dead_code)]
68pub struct DecodedPayload {
69    pub encoded: String,
70    pub decoded: String,
71    pub observation_index: usize,
72}
73
74#[derive(Debug)]
75pub struct CredentialForm {
76    pub action_url: String,
77    pub fields: Vec<String>,
78}
79
80#[derive(Debug)]
81pub struct ExfilData {
82    pub data_type: String,
83    pub encoding: String,
84    pub destination: String,
85}
86
87#[derive(Debug)]
88pub struct Persistence {
89    pub mechanism: String,
90    pub key: String,
91    pub value: String,
92}
93
94/// Extract IOCs from observations.
95pub fn extract_iocs(observations: &[Observation]) -> IocReport {
96    let mut report = IocReport::default();
97    let mut seen_urls = HashSet::new();
98    let mut seen_domains = HashSet::new();
99    let mut current_form_action = String::new();
100    let mut current_form_fields = Vec::new();
101
102    for (i, obs) in observations.iter().enumerate() {
103        match obs {
104            Observation::ApiCall { api, args, .. } => {
105                let args_str: Vec<String> = args.iter().map(|a| a.to_string()).collect();
106                let first_arg = parse_first_arg(args);
107
108                // C2 URLs from fetch/XHR
109                if (api == "fetch" || api.contains("xhr.send"))
110                    && let Some(url) = &first_arg
111                    && url.starts_with("http")
112                    && seen_urls.insert(url.clone())
113                {
114                    let contains_encoded = url.contains("base64") || has_base64_segments(url);
115                    report.c2_urls.push(C2Url {
116                        url: url.clone(),
117                        method: parse_second_arg(args).unwrap_or_else(|| "GET".into()),
118                        contains_encoded_data: contains_encoded,
119                        observation_index: i,
120                    });
121                    if let Some(domain) = extract_domain(url)
122                        && seen_domains.insert(domain.clone())
123                    {
124                        report.domains.push(domain);
125                    }
126
127                    // Check for exfiltrated data in URL
128                    if url.contains("cookie") || url.contains("c=") {
129                        report.exfiltrated_data.push(ExfilData {
130                            data_type: "cookies".into(),
131                            encoding: if has_base64_segments(url) {
132                                "base64"
133                            } else {
134                                "plaintext"
135                            }
136                            .into(),
137                            destination: url.clone(),
138                        });
139                    }
140                    if url.contains("ua=") || url.contains("useragent") {
141                        report.exfiltrated_data.push(ExfilData {
142                            data_type: "user-agent".into(),
143                            encoding: if has_base64_segments(url) {
144                                "base64"
145                            } else {
146                                "plaintext"
147                            }
148                            .into(),
149                            destination: url.clone(),
150                        });
151                    }
152                }
153
154                // Taint sink reached — confirmed vulnerability
155                if api == "taint_sink_reached"
156                    && let Some(sink) = &first_arg
157                {
158                    report.decoded_payloads.push(DecodedPayload {
159                        encoded: format!("TAINT→{sink}"),
160                        decoded: format!("User-controlled data reached {sink}()"),
161                        observation_index: i,
162                    });
163                }
164
165                // Decoded payloads from eval
166                if (api == "eval" || api == "Function")
167                    && let Some(code) = &first_arg
168                    && !code.is_empty()
169                    && code.len() > 5
170                {
171                    report.decoded_payloads.push(DecodedPayload {
172                        encoded: format!("{api}(...)"),
173                        decoded: code.clone(),
174                        observation_index: i,
175                    });
176                }
177
178                // Form action set
179                if api.contains("setAttribute") {
180                    let attr_name = parse_second_arg(args).unwrap_or_default();
181                    let attr_value = parse_third_arg(args).unwrap_or_default();
182                    if attr_name == "action" && attr_value.starts_with("http") {
183                        current_form_action = attr_value.clone();
184                        if let Some(domain) = extract_domain(&attr_value)
185                            && seen_domains.insert(domain.clone())
186                        {
187                            report.domains.push(domain);
188                        }
189                    }
190                    if attr_name == "type" {
191                        current_form_fields.push(attr_value.clone());
192                    }
193                    if attr_name == "name" && !attr_value.is_empty() {
194                        // Update the last field with its name
195                        if let Some(last) = current_form_fields.last_mut() {
196                            *last = format!("{} ({})", last, attr_value);
197                        }
198                    }
199                }
200
201                // Redirect
202                if (api.contains("location.href.set")
203                    || api.contains("location.assign")
204                    || api.contains("location.replace"))
205                    && let Some(url) = &first_arg
206                    && url.starts_with("http")
207                {
208                    report.redirects.push(url.clone());
209                    if let Some(domain) = extract_domain(url)
210                        && seen_domains.insert(domain.clone())
211                    {
212                        report.domains.push(domain);
213                    }
214                }
215
216                // Crypto wallet IOCs
217                if api == "ethereum.request"
218                    || api.starts_with("crypto.sign")
219                    || api.starts_with("crypto.send")
220                    || api.starts_with("crypto.solana")
221                    || api.starts_with("crypto.chain")
222                    || api == "solana.connect"
223                {
224                    let method = first_arg.clone().unwrap_or_default();
225                    let chain = if api.contains("solana") {
226                        "solana"
227                    } else {
228                        "ethereum"
229                    };
230                    let signing = if api.contains("sign") || api.contains("send_transaction") {
231                        Some(method.clone())
232                    } else {
233                        None
234                    };
235
236                    // Extract wallet addresses from args
237                    let mut addresses = Vec::new();
238                    for arg_str in &args_str {
239                        // Ethereum addresses: 0x + 40 hex chars
240                        for word in arg_str.split(|c: char| !c.is_ascii_alphanumeric() && c != 'x')
241                        {
242                            if word.starts_with("0x")
243                                && word.len() >= 42
244                                && word[2..].chars().all(|c| c.is_ascii_hexdigit())
245                            {
246                                addresses.push(word.to_string());
247                            }
248                        }
249                    }
250
251                    report.crypto_iocs.push(CryptoIoc {
252                        chain: chain.into(),
253                        method,
254                        addresses,
255                        signing_method: signing,
256                        observation_index: i,
257                    });
258                }
259
260                // Clipboard IOCs
261                if api.starts_with("clipboard.") {
262                    let operation = if api.contains("write") {
263                        "write"
264                    } else {
265                        "read"
266                    };
267                    report.clipboard_iocs.push(ClipboardIoc {
268                        operation: operation.into(),
269                        content: if operation == "write" {
270                            first_arg.clone()
271                        } else {
272                            None
273                        },
274                        observation_index: i,
275                    });
276                }
277
278                // localStorage/sessionStorage persistence
279                if api.contains("Storage.setItem") || api.contains("storage.set") {
280                    let key = first_arg.unwrap_or_default();
281                    let value = parse_second_arg(args).unwrap_or_default();
282                    report.persistence.push(Persistence {
283                        mechanism: if api.contains("local") {
284                            "localStorage"
285                        } else {
286                            "sessionStorage"
287                        }
288                        .into(),
289                        key,
290                        value,
291                    });
292                }
293            }
294
295            Observation::NetworkRequest { url, method, .. } => {
296                if seen_urls.insert(url.clone()) {
297                    report.c2_urls.push(C2Url {
298                        url: url.clone(),
299                        method: method.clone(),
300                        contains_encoded_data: has_base64_segments(url),
301                        observation_index: i,
302                    });
303                }
304            }
305
306            _ => {}
307        }
308    }
309
310    // Finalize credential form if we collected fields
311    if !current_form_action.is_empty() || !current_form_fields.is_empty() {
312        report.credential_forms.push(CredentialForm {
313            action_url: current_form_action,
314            fields: current_form_fields,
315        });
316    }
317
318    report
319}
320
321impl IocReport {
322    /// Format as a structured terminal report.
323    pub fn format_text(&self) -> String {
324        let mut out = String::new();
325        let bold = "\x1b[1m";
326        let red = "\x1b[91m";
327        let yellow = "\x1b[33m";
328        let cyan = "\x1b[36m";
329        let dim = "\x1b[2m";
330        let reset = "\x1b[0m";
331
332        if self.is_empty() {
333            return format!("{dim}No IOCs extracted.{reset}\n");
334        }
335
336        out.push_str(&format!("\n{bold}IOCs Extracted:{reset}\n"));
337
338        if !self.c2_urls.is_empty() {
339            out.push_str(&format!("  {red}{bold}C2/Exfiltration URLs:{reset}\n"));
340            for c2 in &self.c2_urls {
341                let encoded_marker = if c2.contains_encoded_data {
342                    " [encoded data]"
343                } else {
344                    ""
345                };
346                out.push_str(&format!(
347                    "    {red}{} {}{encoded_marker}{reset}\n",
348                    c2.method, c2.url
349                ));
350            }
351            out.push('\n');
352        }
353
354        if !self.decoded_payloads.is_empty() {
355            out.push_str(&format!("  {yellow}{bold}Decoded Payloads:{reset}\n"));
356            for payload in &self.decoded_payloads {
357                let preview = if payload.decoded.len() > 120 {
358                    format!("{}...", &payload.decoded[..120])
359                } else {
360                    payload.decoded.clone()
361                };
362                out.push_str(&format!(
363                    "    {yellow}{} → {preview}{reset}\n",
364                    payload.encoded
365                ));
366            }
367            out.push('\n');
368        }
369
370        if !self.credential_forms.is_empty() {
371            out.push_str(&format!("  {red}{bold}Credential Harvesting:{reset}\n"));
372            for form in &self.credential_forms {
373                if !form.action_url.is_empty() {
374                    out.push_str(&format!(
375                        "    {red}Form action: {}{reset}\n",
376                        form.action_url
377                    ));
378                }
379                for field in &form.fields {
380                    out.push_str(&format!("    {red}Field: {field}{reset}\n"));
381                }
382            }
383            out.push('\n');
384        }
385
386        if !self.exfiltrated_data.is_empty() {
387            out.push_str(&format!("  {yellow}{bold}Exfiltrated Data:{reset}\n"));
388            for exfil in &self.exfiltrated_data {
389                out.push_str(&format!(
390                    "    {yellow}{} ({}) → {}{reset}\n",
391                    exfil.data_type,
392                    exfil.encoding,
393                    if exfil.destination.len() > 80 {
394                        format!("{}...", &exfil.destination[..80])
395                    } else {
396                        exfil.destination.clone()
397                    }
398                ));
399            }
400            out.push('\n');
401        }
402
403        if !self.persistence.is_empty() {
404            out.push_str(&format!("  {cyan}{bold}Persistence:{reset}\n"));
405            for p in &self.persistence {
406                out.push_str(&format!(
407                    "    {cyan}{}.{} = {}{reset}\n",
408                    p.mechanism, p.key, p.value
409                ));
410            }
411            out.push('\n');
412        }
413
414        if !self.redirects.is_empty() {
415            out.push_str(&format!("  {yellow}{bold}Redirects:{reset}\n"));
416            for r in &self.redirects {
417                out.push_str(&format!("    {yellow}→ {r}{reset}\n"));
418            }
419            out.push('\n');
420        }
421
422        if !self.crypto_iocs.is_empty() {
423            out.push_str(&format!("  {red}{bold}Crypto Wallet IOCs:{reset}\n"));
424            for ioc in &self.crypto_iocs {
425                let signing = ioc.signing_method.as_deref().unwrap_or("—");
426                out.push_str(&format!(
427                    "    {red}[{}] {}: signing={signing}{reset}\n",
428                    ioc.chain, ioc.method
429                ));
430                for addr in &ioc.addresses {
431                    out.push_str(&format!("      {red}Address: {addr}{reset}\n"));
432                }
433            }
434            out.push('\n');
435        }
436
437        if !self.clipboard_iocs.is_empty() {
438            out.push_str(&format!("  {yellow}{bold}Clipboard IOCs:{reset}\n"));
439            for ioc in &self.clipboard_iocs {
440                let content = ioc.content.as_deref().unwrap_or("(read)");
441                out.push_str(&format!(
442                    "    {yellow}{}: {content}{reset}\n",
443                    ioc.operation
444                ));
445            }
446            out.push('\n');
447        }
448
449        if !self.domains.is_empty() {
450            out.push_str(&format!(
451                "  {dim}Domains: {}{reset}\n",
452                self.domains.join(", ")
453            ));
454        }
455
456        out
457    }
458
459    pub fn is_empty(&self) -> bool {
460        self.c2_urls.is_empty()
461            && self.decoded_payloads.is_empty()
462            && self.credential_forms.is_empty()
463            && self.exfiltrated_data.is_empty()
464            && self.persistence.is_empty()
465            && self.redirects.is_empty()
466            && self.crypto_iocs.is_empty()
467            && self.clipboard_iocs.is_empty()
468    }
469}
470
471fn parse_first_arg(args: &[jsdet_core::observation::Value]) -> Option<String> {
472    args.first().and_then(|v| match v {
473        jsdet_core::observation::Value::String(s, _) => {
474            if s.starts_with('[') {
475                serde_json::from_str::<Vec<String>>(s)
476                    .ok()
477                    .and_then(|arr| arr.into_iter().next())
478            } else {
479                Some(s.clone())
480            }
481        }
482        _ => Some(v.to_string()),
483    })
484}
485
486fn parse_second_arg(args: &[jsdet_core::observation::Value]) -> Option<String> {
487    args.first().and_then(|v| match v {
488        jsdet_core::observation::Value::String(s, _) if s.starts_with('[') => {
489            serde_json::from_str::<Vec<String>>(s)
490                .ok()
491                .and_then(|arr| arr.into_iter().nth(1))
492        }
493        _ => args.get(1).map(|v| v.to_string()),
494    })
495}
496
497fn parse_third_arg(args: &[jsdet_core::observation::Value]) -> Option<String> {
498    args.first().and_then(|v| match v {
499        jsdet_core::observation::Value::String(s, _) if s.starts_with('[') => {
500            serde_json::from_str::<Vec<String>>(s)
501                .ok()
502                .and_then(|arr| arr.into_iter().nth(2))
503        }
504        _ => args.get(2).map(|v| v.to_string()),
505    })
506}
507
508fn extract_domain(url: &str) -> Option<String> {
509    let after_scheme = url.split("://").nth(1)?;
510    let host = after_scheme.split('/').next()?;
511    let host = host.split(':').next()?;
512    if host.is_empty() || host == "127.0.0.1" || host == "localhost" {
513        None
514    } else {
515        Some(host.to_string())
516    }
517}
518
519fn has_base64_segments(url: &str) -> bool {
520    // Check for long base64-like segments in URL parameters
521    if let Some(query) = url.split('?').nth(1) {
522        for param in query.split('&') {
523            if let Some(value) = param.split('=').nth(1)
524                && value.len() > 20
525                && value
526                    .chars()
527                    .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
528            {
529                return true;
530            }
531        }
532    }
533    false
534}