1use std::collections::HashSet;
13
14use jsdet_core::Observation;
15
16#[derive(Debug, Default)]
18pub struct IocReport {
19 pub c2_urls: Vec<C2Url>,
21 pub decoded_payloads: Vec<DecodedPayload>,
23 pub credential_forms: Vec<CredentialForm>,
25 pub exfiltrated_data: Vec<ExfilData>,
27 pub persistence: Vec<Persistence>,
29 pub redirects: Vec<String>,
31 pub domains: Vec<String>,
33 pub crypto_iocs: Vec<CryptoIoc>,
35 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
94pub 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 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 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 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 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 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 if let Some(last) = current_form_fields.last_mut() {
196 *last = format!("{} ({})", last, attr_value);
197 }
198 }
199 }
200
201 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 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 let mut addresses = Vec::new();
238 for arg_str in &args_str {
239 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 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 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 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 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 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}