1use std::sync::{Arc, Mutex};
10
11use jsdet_core::bridge::Bridge;
12use jsdet_core::observation::{Observation, Value};
13
14use crate::manifest::Manifest;
15use crate::profile::AnalysisProfile;
16use crate::state::ExtensionState;
17
18pub struct ChromeExtBridge {
20 manifest: Manifest,
22 profile: AnalysisProfile,
24 state: ExtensionState,
26 observations: Arc<Mutex<Vec<Observation>>>,
28}
29
30impl ChromeExtBridge {
31 pub fn new(manifest: Manifest, profile: AnalysisProfile, state: ExtensionState) -> Self {
33 Self {
34 manifest,
35 profile,
36 state,
37 observations: Arc::new(Mutex::new(Vec::new())),
38 }
39 }
40
41 pub fn take_observations(&self) -> Vec<Observation> {
44 let mut guard = self
45 .observations
46 .lock()
47 .unwrap_or_else(std::sync::PoisonError::into_inner);
48 std::mem::take(&mut *guard)
49 }
50
51 fn observe(&self, obs: Observation) {
52 if let Ok(mut guard) = self.observations.lock() {
54 guard.push(obs);
55 }
56 }
57
58 fn handle_tabs(&self, method: &str, args: &[Value]) -> Result<Value, String> {
59 match method {
60 "query" => {
61 let guard = self
63 .state
64 .tabs
65 .lock()
66 .unwrap_or_else(std::sync::PoisonError::into_inner);
67 let json = serde_json::to_string(&*guard).unwrap_or_default();
68 Ok(Value::json(json))
69 }
70 "create" => {
71 self.observe(Observation::ApiCall {
72 api: "chrome.tabs.create".to_string(),
73 args: args.to_vec(),
74 result: Value::json(r#"{"id": 999}"#),
75 });
76 Ok(Value::json(r#"{"id": 999}"#))
77 }
78 "update" => {
79 self.observe(Observation::ApiCall {
80 api: "chrome.tabs.update".into(),
81 args: args.to_vec(),
82 result: Value::Null,
83 });
84 if let Some(sink) = self.profile.is_sink("chrome.tabs.update") {
86 self.observe(Observation::ApiCall {
87 api: format!("SINK:chrome.tabs.update [{}]", sink.cwe),
88 args: args.to_vec(),
89 result: Value::Null,
90 });
91 }
92 Ok(Value::Null)
93 }
94 "executeScript" | "sendMessage" => {
95 self.observe(Observation::ApiCall {
96 api: format!("chrome.tabs.{method}"),
97 args: args.to_vec(),
98 result: Value::Null,
99 });
100 if let Some(sink) = self.profile.is_sink(&format!("chrome.tabs.{method}")) {
101 self.observe(Observation::ApiCall {
102 api: format!("SINK:chrome.tabs.{method} [{}]", sink.cwe),
103 args: args.to_vec(),
104 result: Value::Null,
105 });
106 }
107 Ok(Value::Null)
108 }
109 _ => Err(format!("chrome.tabs.{method} is not defined")),
110 }
111 }
112
113 fn handle_cookies(&self, method: &str, args: &[Value]) -> Result<Value, String> {
114 match method {
115 "getAll" | "get" => {
116 let guard = self
118 .state
119 .cookies
120 .lock()
121 .unwrap_or_else(std::sync::PoisonError::into_inner);
122 let json = serde_json::to_string(&*guard).unwrap_or_default();
123 self.observe(Observation::ApiCall {
124 api: format!("chrome.cookies.{method}"),
125 args: args.to_vec(),
126 result: Value::json(json.clone()),
127 });
128 Ok(Value::json(json))
129 }
130 "set" | "remove" => {
131 self.observe(Observation::ApiCall {
132 api: format!("chrome.cookies.{method}"),
133 args: args.to_vec(),
134 result: Value::Null,
135 });
136 Ok(Value::Null)
137 }
138 _ => Err(format!("chrome.cookies.{method} is not defined")),
139 }
140 }
141
142 fn handle_storage(&self, method: &str, args: &[Value]) -> Result<Value, String> {
143 let (area, op) = if method.contains('.') {
145 let parts: Vec<&str> = method.splitn(2, '.').collect();
146 (parts[0], parts[1])
147 } else {
148 ("local", method)
149 };
150
151 let storage = match area {
152 "sync" => &self.state.storage_sync,
153 _ => &self.state.storage_local,
154 };
155
156 match op {
157 "get" => {
158 let data = storage.lock().unwrap();
159 let json = serde_json::to_string(&*data).unwrap_or_default();
160 self.observe(Observation::ApiCall {
161 api: format!("chrome.storage.{area}.get"),
162 args: args.to_vec(),
163 result: Value::json(json.clone()),
164 });
165 Ok(Value::json(json))
166 }
167 "set" => {
168 self.observe(Observation::ApiCall {
169 api: format!("chrome.storage.{area}.set"),
170 args: args.to_vec(),
171 result: Value::Null,
172 });
173 if let Some(Value::Json(json, _)) = args.first()
176 && let Ok(map) =
177 serde_json::from_str::<std::collections::HashMap<String, String>>(json)
178 && let Ok(mut guard) = storage.lock()
179 {
180 guard.extend(map);
181 }
182 Ok(Value::Null)
183 }
184 "remove" | "clear" => {
185 self.observe(Observation::ApiCall {
186 api: format!("chrome.storage.{area}.{op}"),
187 args: args.to_vec(),
188 result: Value::Null,
189 });
190 if op == "clear" {
191 if let Ok(mut guard) = storage.lock() {
193 guard.clear();
194 }
195 }
196 Ok(Value::Null)
197 }
198 _ => Err(format!("chrome.storage.{area}.{op} is not defined")),
199 }
200 }
201
202 fn handle_runtime(&self, method: &str, args: &[Value]) -> Result<Value, String> {
203 match method {
204 "getURL" => {
205 let path = args.first().and_then(|v| v.as_str()).unwrap_or("");
206 let url = format!("chrome-extension://{}/{}", self.state.extension_id, path);
207 Ok(Value::string(url))
208 }
209 "sendMessage" => {
210 self.observe(Observation::ApiCall {
211 api: "chrome.runtime.sendMessage".into(),
212 args: args.to_vec(),
213 result: Value::Null,
214 });
215 if self
217 .profile
218 .is_source("chrome.runtime.sendMessage")
219 .is_some()
220 {
221 self.observe(Observation::ApiCall {
222 api: "SOURCE:chrome.runtime.sendMessage".into(),
223 args: args.to_vec(),
224 result: Value::Null,
225 });
226 }
227 Ok(Value::Null)
228 }
229 "getManifest" => {
230 let json = serde_json::to_string(&self.manifest).unwrap_or_default();
231 Ok(Value::json(json))
232 }
233 "id" => Ok(Value::string(self.state.extension_id.clone())),
234 _ => Err(format!("chrome.runtime.{method} is not defined")),
235 }
236 }
237
238 fn handle_scripting(&self, method: &str, args: &[Value]) -> Result<Value, String> {
239 match method {
240 "executeScript" | "insertCSS" | "registerContentScripts" => {
241 self.observe(Observation::ApiCall {
242 api: format!("chrome.scripting.{method}"),
243 args: args.to_vec(),
244 result: Value::Null,
245 });
246 if let Some(sink) = self.profile.is_sink(&format!("chrome.scripting.{method}")) {
247 self.observe(Observation::ApiCall {
248 api: format!("SINK:chrome.scripting.{method} [{}]", sink.cwe),
249 args: args.to_vec(),
250 result: Value::Null,
251 });
252 }
253 Ok(Value::Null)
254 }
255 _ => Err(format!("chrome.scripting.{method} is not defined")),
256 }
257 }
258
259 fn handle_web_request(&self, method: &str, args: &[Value]) -> Result<Value, String> {
260 self.observe(Observation::ApiCall {
261 api: format!("chrome.webRequest.{method}"),
262 args: args.to_vec(),
263 result: Value::Null,
264 });
265 Ok(Value::Null)
266 }
267
268 fn handle_alarms(&self, method: &str, args: &[Value]) -> Result<Value, String> {
269 match method {
270 "create" => {
271 self.observe(Observation::ApiCall {
272 api: "chrome.alarms.create".into(),
273 args: args.to_vec(),
274 result: Value::Null,
275 });
276 Ok(Value::Null)
277 }
278 "get" | "getAll" | "clear" | "clearAll" => {
279 let guard = self
281 .state
282 .alarms
283 .lock()
284 .unwrap_or_else(std::sync::PoisonError::into_inner);
285 let json = serde_json::to_string(&*guard).unwrap_or_default();
286 Ok(Value::json(json))
287 }
288 _ => Err(format!("chrome.alarms.{method} is not defined")),
289 }
290 }
291
292 fn handle_permissions(&self, method: &str, _args: &[Value]) -> Result<Value, String> {
293 match method {
294 "getAll" => {
295 let perms = serde_json::json!({
296 "permissions": self.manifest.permissions,
297 "origins": self.manifest.host_permissions,
298 });
299 Ok(Value::json(perms.to_string()))
300 }
301 "contains" => Ok(Value::Bool(true)), "request" => Ok(Value::Bool(true)),
303 _ => Err(format!("chrome.permissions.{method} is not defined")),
304 }
305 }
306}
307
308impl Bridge for ChromeExtBridge {
309 fn call(&self, api: &str, args: &[Value]) -> Result<Value, String> {
310 if let Some(method) = api.strip_prefix("chrome.tabs.") {
312 return self.handle_tabs(method, args);
313 }
314 if let Some(method) = api.strip_prefix("chrome.cookies.") {
315 return self.handle_cookies(method, args);
316 }
317 if let Some(method) = api.strip_prefix("chrome.storage.") {
318 return self.handle_storage(method, args);
319 }
320 if let Some(method) = api.strip_prefix("chrome.runtime.") {
321 return self.handle_runtime(method, args);
322 }
323 if let Some(method) = api.strip_prefix("chrome.scripting.") {
324 return self.handle_scripting(method, args);
325 }
326 if let Some(method) = api.strip_prefix("chrome.webRequest.") {
327 return self.handle_web_request(method, args);
328 }
329 if let Some(method) = api.strip_prefix("chrome.alarms.") {
330 return self.handle_alarms(method, args);
331 }
332 if let Some(method) = api.strip_prefix("chrome.permissions.") {
333 return self.handle_permissions(method, args);
334 }
335
336 self.observe(Observation::ApiCall {
342 api: api.into(),
343 args: args.to_vec(),
344 result: Value::Null,
345 });
346
347 Err(format!("{api} is not defined"))
348 }
349
350 fn get_property(&self, object: &str, property: &str) -> Result<Value, String> {
351 match (object, property) {
352 ("chrome.runtime", "id") => Ok(Value::string(self.state.extension_id.clone())),
353 ("chrome.runtime", "lastError") => Ok(Value::Null),
354 _ => Err(format!("{object}.{property} is not defined")),
355 }
356 }
357
358 fn set_property(&self, object: &str, property: &str, value: &Value) -> Result<(), String> {
359 self.observe(Observation::PropertyWrite {
360 object: object.into(),
361 property: property.into(),
362 value: value.clone(),
363 });
364 Ok(())
365 }
366
367 fn provided_globals(&self) -> Vec<String> {
368 vec!["chrome".into()]
369 }
370
371 fn bootstrap_js(&self) -> String {
372 crate::bootstrap::generate_bootstrap(&self.manifest)
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use jsdet_core::observation::TaintLabel;
380
381 fn make_bridge() -> ChromeExtBridge {
382 let manifest = Manifest::parse(
383 r#"{
384 "name": "Test",
385 "manifest_version": 3,
386 "version": "1.0",
387 "permissions": ["tabs", "storage", "cookies"]
388 }"#,
389 )
390 .unwrap();
391 let profile = AnalysisProfile::default();
392 let state = ExtensionState::default();
393 ChromeExtBridge::new(manifest, profile, state)
394 }
395
396 fn make_bridge_with_profile(profile: AnalysisProfile) -> ChromeExtBridge {
397 let manifest = Manifest::parse(
398 r#"{
399 "name": "Test",
400 "manifest_version": 3,
401 "version": "1.0",
402 "permissions": ["tabs", "storage", "cookies", "webRequest", "alarms"]
403 }"#,
404 )
405 .unwrap();
406 let state = ExtensionState::default();
407 ChromeExtBridge::new(manifest, profile, state)
408 }
409
410 #[test]
415 fn tabs_query_returns_tabs() {
416 let bridge = make_bridge();
417 let result = bridge.call("chrome.tabs.query", &[]).unwrap();
418 assert!(matches!(result, Value::Json(..)));
419 }
420
421 #[test]
422 fn storage_set_and_get() {
423 let bridge = make_bridge();
424
425 bridge
427 .call(
428 "chrome.storage.local.set",
429 &[Value::json(r#"{"key1": "value1"}"#)],
430 )
431 .unwrap();
432
433 let result = bridge.call("chrome.storage.local.get", &[]).unwrap();
435 if let Value::Json(json, _) = result {
436 assert!(json.contains("key1"));
437 }
438 }
439
440 #[test]
441 fn runtime_get_url() {
442 let bridge = make_bridge();
443 let result = bridge
444 .call("chrome.runtime.getURL", &[Value::string("popup.html")])
445 .unwrap();
446 if let Value::String(url, _) = result {
447 assert!(url.contains("popup.html"));
448 assert!(url.starts_with("chrome-extension://"));
449 }
450 }
451
452 #[test]
458 fn observations_collected() {
459 let bridge = make_bridge();
460 bridge
461 .call(
462 "chrome.tabs.create",
463 &[Value::json(r#"{"url":"https://evil.com"}"#)],
464 )
465 .unwrap();
466 bridge.call("chrome.cookies.getAll", &[]).unwrap();
467
468 let obs = bridge.take_observations();
469 assert!(obs.len() >= 2);
470 }
471
472 #[test]
473 fn unknown_api_returns_error() {
474 let bridge = make_bridge();
475 assert!(bridge.call("chrome.nonexistent.method", &[]).is_err());
476 }
477
478 #[test]
479 fn provided_globals() {
480 let bridge = make_bridge();
481 assert_eq!(bridge.provided_globals(), vec!["chrome"]);
482 }
483
484 #[test]
489 fn tabs_query_with_args() {
490 let bridge = make_bridge();
491 let result = bridge
492 .call("chrome.tabs.query", &[Value::json(r#"{"active": true}"#)])
493 .unwrap();
494 assert!(matches!(result, Value::Json(..)));
495 }
496
497 #[test]
498 fn tabs_create() {
499 let bridge = make_bridge();
500 let result = bridge
501 .call(
502 "chrome.tabs.create",
503 &[Value::json(r#"{"url": "https://example.com"}"#)],
504 )
505 .unwrap();
506 assert!(matches!(result, Value::Json(..)));
507
508 let obs = bridge.take_observations();
509 assert!(obs.iter().any(|o| matches!(o,
510 Observation::ApiCall { api, .. } if api == "chrome.tabs.create"
511 )));
512 }
513
514 #[test]
515 fn tabs_update() {
516 let bridge = make_bridge();
517 let result = bridge
518 .call(
519 "chrome.tabs.update",
520 &[
521 Value::string("1"),
522 Value::json(r#"{"url": "https://newurl.com"}"#),
523 ],
524 )
525 .unwrap();
526 assert!(matches!(result, Value::Null));
527 }
528
529 #[test]
530 fn tabs_execute_script() {
531 let bridge = make_bridge();
532 let result = bridge
533 .call(
534 "chrome.tabs.executeScript",
535 &[Value::string("1"), Value::json(r#"{"code": "alert(1)"}"#)],
536 )
537 .unwrap();
538 assert!(matches!(result, Value::Null));
539
540 let obs = bridge.take_observations();
541 assert!(obs.iter().any(|o| matches!(o,
542 Observation::ApiCall { api, .. } if api == "chrome.tabs.executeScript"
543 )));
544 }
545
546 #[test]
547 fn tabs_send_message() {
548 let bridge = make_bridge();
549 let result = bridge
550 .call(
551 "chrome.tabs.sendMessage",
552 &[Value::string("1"), Value::json(r#"{"action": "test"}"#)],
553 )
554 .unwrap();
555 assert!(matches!(result, Value::Null));
556 }
557
558 #[test]
559 fn tabs_unknown_method() {
560 let bridge = make_bridge();
561 let result = bridge.call("chrome.tabs.nonexistent", &[]);
562 assert!(result.is_err());
563 assert!(result.unwrap_err().contains("not defined"));
564 }
565
566 #[test]
571 fn cookies_get_all() {
572 let bridge = make_bridge();
573 let result = bridge
574 .call(
575 "chrome.cookies.getAll",
576 &[Value::json(r#"{"domain": "example.com"}"#)],
577 )
578 .unwrap();
579 assert!(matches!(result, Value::Json(..)));
580
581 let obs = bridge.take_observations();
582 assert!(obs.iter().any(|o| matches!(o,
583 Observation::ApiCall { api, .. } if api == "chrome.cookies.getAll"
584 )));
585 }
586
587 #[test]
588 fn cookies_get() {
589 let bridge = make_bridge();
590 let result = bridge
591 .call(
592 "chrome.cookies.get",
593 &[Value::Json(
594 r#"{"url": "https://example.com", "name": "session"}"#.into(),
595 TaintLabel::default(),
596 )],
597 )
598 .unwrap();
599 assert!(matches!(result, Value::Json(..)));
600
601 let obs = bridge.take_observations();
602 assert!(obs.iter().any(|o| matches!(o,
603 Observation::ApiCall { api, .. } if api == "chrome.cookies.get"
604 )));
605 }
606
607 #[test]
608 fn cookies_set() {
609 let bridge = make_bridge();
610 let result = bridge
611 .call(
612 "chrome.cookies.set",
613 &[Value::Json(
614 r#"{"url": "https://example.com", "name": "new", "value": "value"}"#.into(),
615 TaintLabel::default(),
616 )],
617 )
618 .unwrap();
619 assert!(matches!(result, Value::Null));
620
621 let obs = bridge.take_observations();
622 assert!(obs.iter().any(|o| matches!(o,
623 Observation::ApiCall { api, .. } if api == "chrome.cookies.set"
624 )));
625 }
626
627 #[test]
628 fn cookies_remove() {
629 let bridge = make_bridge();
630 let result = bridge
631 .call(
632 "chrome.cookies.remove",
633 &[Value::Json(
634 r#"{"url": "https://example.com", "name": "session"}"#.into(),
635 TaintLabel::default(),
636 )],
637 )
638 .unwrap();
639 assert!(matches!(result, Value::Null));
640
641 let obs = bridge.take_observations();
642 assert!(obs.iter().any(|o| matches!(o,
643 Observation::ApiCall { api, .. } if api == "chrome.cookies.remove"
644 )));
645 }
646
647 #[test]
648 fn cookies_unknown_method() {
649 let bridge = make_bridge();
650 let result = bridge.call("chrome.cookies.nonexistent", &[]);
651 assert!(result.is_err());
652 }
653
654 #[test]
659 fn storage_local_get_empty() {
660 let bridge = make_bridge();
661 let result = bridge.call("chrome.storage.local.get", &[]).unwrap();
662 assert!(matches!(result, Value::Json(..)));
663 }
664
665 #[test]
666 fn storage_local_get_with_keys() {
667 let bridge = make_bridge();
668 let result = bridge
669 .call(
670 "chrome.storage.local.get",
671 &[Value::json(r#"["key1", "key2"]"#)],
672 )
673 .unwrap();
674 assert!(matches!(result, Value::Json(..)));
675 }
676
677 #[test]
678 fn storage_local_set_and_get_roundtrip() {
679 let bridge = make_bridge();
680
681 bridge
683 .call(
684 "chrome.storage.local.set",
685 &[Value::json(r#"{"testkey": "testvalue"}"#)],
686 )
687 .unwrap();
688
689 let result = bridge.call("chrome.storage.local.get", &[]).unwrap();
691 if let Value::Json(json, _) = result {
692 assert!(json.contains("testkey"));
693 assert!(json.contains("testvalue"));
694 }
695 }
696
697 #[test]
698 fn storage_local_remove() {
699 let bridge = make_bridge();
700 let result = bridge
701 .call("chrome.storage.local.remove", &[Value::json(r#""key1""#)])
702 .unwrap();
703 assert!(matches!(result, Value::Null));
704 }
705
706 #[test]
707 fn storage_local_clear() {
708 let bridge = make_bridge();
709
710 bridge
712 .call(
713 "chrome.storage.local.set",
714 &[Value::json(r#"{"temp": "value"}"#)],
715 )
716 .unwrap();
717
718 let result = bridge.call("chrome.storage.local.clear", &[]).unwrap();
720 assert!(matches!(result, Value::Null));
721
722 let get_result = bridge.call("chrome.storage.local.get", &[]).unwrap();
724 if let Value::Json(json, _) = get_result {
725 assert_eq!(json, "{}");
726 }
727 }
728
729 #[test]
730 fn storage_sync_get() {
731 let bridge = make_bridge();
732 let result = bridge.call("chrome.storage.sync.get", &[]).unwrap();
733 assert!(matches!(result, Value::Json(..)));
734 }
735
736 #[test]
737 fn storage_sync_set() {
738 let bridge = make_bridge();
739 let result = bridge
740 .call(
741 "chrome.storage.sync.set",
742 &[Value::json(r#"{"synckey": "syncvalue"}"#)],
743 )
744 .unwrap();
745 assert!(matches!(result, Value::Null));
746 }
747
748 #[test]
749 fn storage_sync_remove() {
750 let bridge = make_bridge();
751 let result = bridge
752 .call("chrome.storage.sync.remove", &[Value::json(r#""key""#)])
753 .unwrap();
754 assert!(matches!(result, Value::Null));
755 }
756
757 #[test]
758 fn storage_sync_clear() {
759 let bridge = make_bridge();
760 let result = bridge.call("chrome.storage.sync.clear", &[]).unwrap();
761 assert!(matches!(result, Value::Null));
762 }
763
764 #[test]
765 fn storage_shortcut_local_get() {
766 let bridge = make_bridge();
768 let result = bridge.call("chrome.storage.get", &[]).unwrap();
769 assert!(matches!(result, Value::Json(..)));
770 }
771
772 #[test]
773 fn storage_unknown_area() {
774 let bridge = make_bridge();
775 let result = bridge.call("chrome.storage.unknown.get", &[]);
777 assert!(result.is_ok() || result.is_err());
779 }
780
781 #[test]
786 fn runtime_get_url_empty_path() {
787 let bridge = make_bridge();
788 let result = bridge.call("chrome.runtime.getURL", &[]).unwrap();
789 if let Value::String(url, _) = result {
790 assert!(url.starts_with("chrome-extension://"));
791 assert!(url.ends_with("/"));
792 }
793 }
794
795 #[test]
796 fn runtime_get_url_with_path() {
797 let bridge = make_bridge();
798 let result = bridge
799 .call("chrome.runtime.getURL", &[Value::string("js/content.js")])
800 .unwrap();
801 if let Value::String(url, _) = result {
802 assert!(url.contains("js/content.js"));
803 }
804 }
805
806 #[test]
807 fn runtime_send_message() {
808 let bridge = make_bridge();
809 let result = bridge
810 .call(
811 "chrome.runtime.sendMessage",
812 &[Value::json(r#"{"action": "test"}"#)],
813 )
814 .unwrap();
815 assert!(matches!(result, Value::Null));
816
817 let obs = bridge.take_observations();
818 assert!(obs.iter().any(|o| matches!(o,
819 Observation::ApiCall { api, .. } if api == "chrome.runtime.sendMessage"
820 )));
821 }
822
823 #[test]
824 fn runtime_get_manifest() {
825 let bridge = make_bridge();
826 let result = bridge.call("chrome.runtime.getManifest", &[]).unwrap();
827 if let Value::Json(json, _) = result {
828 assert!(json.contains("Test"));
829 assert!(json.contains("manifest_version"));
830 }
831 }
832
833 #[test]
834 fn runtime_id() {
835 let bridge = make_bridge();
836 let result = bridge.call("chrome.runtime.id", &[]).unwrap();
837 if let Value::String(id, _) = result {
838 assert!(!id.is_empty());
839 }
840 }
841
842 #[test]
843 fn runtime_unknown_method() {
844 let bridge = make_bridge();
845 let result = bridge.call("chrome.runtime.nonexistent", &[]);
846 assert!(result.is_err());
847 }
848
849 #[test]
854 fn scripting_execute_script() {
855 let bridge = make_bridge();
856 let result = bridge
857 .call(
858 "chrome.scripting.executeScript",
859 &[Value::Json(
860 r#"{"target": {"tabId": 1}, "func": "() => {}"}"#.into(),
861 TaintLabel::default(),
862 )],
863 )
864 .unwrap();
865 assert!(matches!(result, Value::Null));
866
867 let obs = bridge.take_observations();
868 assert!(obs.iter().any(|o| matches!(o,
869 Observation::ApiCall { api, .. } if api == "chrome.scripting.executeScript"
870 )));
871 }
872
873 #[test]
874 fn scripting_insert_css() {
875 let bridge = make_bridge();
876 let result = bridge
877 .call(
878 "chrome.scripting.insertCSS",
879 &[Value::Json(
880 r#"{"target": {"tabId": 1}, "css": "body{}"}"#.into(),
881 TaintLabel::default(),
882 )],
883 )
884 .unwrap();
885 assert!(matches!(result, Value::Null));
886
887 let obs = bridge.take_observations();
888 assert!(obs.iter().any(|o| matches!(o,
889 Observation::ApiCall { api, .. } if api == "chrome.scripting.insertCSS"
890 )));
891 }
892
893 #[test]
894 fn scripting_register_content_scripts() {
895 let bridge = make_bridge();
896 let result = bridge
897 .call(
898 "chrome.scripting.registerContentScripts",
899 &[Value::Json(
900 r#"[{"id": "script1", "matches": ["<all_urls>"], "js": ["content.js"]}]"#
901 .into(),
902 TaintLabel::default(),
903 )],
904 )
905 .unwrap();
906 assert!(matches!(result, Value::Null));
907 }
908
909 #[test]
910 fn scripting_unknown_method() {
911 let bridge = make_bridge();
912 let result = bridge.call("chrome.scripting.nonexistent", &[]);
913 assert!(result.is_err());
914 }
915
916 #[test]
921 fn webrequest_on_before_request() {
922 let bridge = make_bridge();
923 let result = bridge
924 .call("chrome.webRequest.onBeforeRequest.addListener", &[])
925 .unwrap();
926 assert!(matches!(result, Value::Null));
927
928 let obs = bridge.take_observations();
929 assert!(obs.iter().any(|o| matches!(o,
930 Observation::ApiCall { api, .. } if api == "chrome.webRequest.onBeforeRequest.addListener"
931 )));
932 }
933
934 #[test]
935 fn webrequest_on_before_send_headers() {
936 let bridge = make_bridge();
937 let result = bridge
938 .call("chrome.webRequest.onBeforeSendHeaders.addListener", &[])
939 .unwrap();
940 assert!(matches!(result, Value::Null));
941 }
942
943 #[test]
944 fn webrequest_any_method() {
945 let bridge = make_bridge();
947 let result = bridge
948 .call("chrome.webRequest.onCompleted.addListener", &[])
949 .unwrap();
950 assert!(matches!(result, Value::Null));
951
952 let result = bridge
953 .call("chrome.webRequest.onErrorOccurred.addListener", &[])
954 .unwrap();
955 assert!(matches!(result, Value::Null));
956 }
957
958 #[test]
963 fn alarms_create() {
964 let bridge = make_bridge();
965 let result = bridge
966 .call(
967 "chrome.alarms.create",
968 &[
969 Value::string("alarm1"),
970 Value::json(r#"{"delayInMinutes": 5}"#),
971 ],
972 )
973 .unwrap();
974 assert!(matches!(result, Value::Null));
975
976 let obs = bridge.take_observations();
977 assert!(obs.iter().any(|o| matches!(o,
978 Observation::ApiCall { api, .. } if api == "chrome.alarms.create"
979 )));
980 }
981
982 #[test]
983 fn alarms_get() {
984 let bridge = make_bridge();
985 let result = bridge
986 .call("chrome.alarms.get", &[Value::string("alarm1")])
987 .unwrap();
988 assert!(matches!(result, Value::Json(..)));
989 }
990
991 #[test]
992 fn alarms_get_all() {
993 let bridge = make_bridge();
994 let result = bridge.call("chrome.alarms.getAll", &[]).unwrap();
995 assert!(matches!(result, Value::Json(..)));
996 }
997
998 #[test]
999 fn alarms_clear() {
1000 let bridge = make_bridge();
1001 let result = bridge
1002 .call("chrome.alarms.clear", &[Value::string("alarm1")])
1003 .unwrap();
1004 assert!(matches!(result, Value::Json(..)));
1005 }
1006
1007 #[test]
1008 fn alarms_clear_all() {
1009 let bridge = make_bridge();
1010 let result = bridge.call("chrome.alarms.clearAll", &[]).unwrap();
1011 assert!(matches!(result, Value::Json(..)));
1012 }
1013
1014 #[test]
1015 fn alarms_unknown_method() {
1016 let bridge = make_bridge();
1017 let result = bridge.call("chrome.alarms.nonexistent", &[]);
1018 assert!(result.is_err());
1019 }
1020
1021 #[test]
1026 fn permissions_get_all() {
1027 let bridge = make_bridge();
1028 let result = bridge.call("chrome.permissions.getAll", &[]).unwrap();
1029 if let Value::Json(json, _) = result {
1030 assert!(json.contains("permissions"));
1031 assert!(json.contains("tabs")); }
1033 }
1034
1035 #[test]
1036 fn permissions_contains() {
1037 let bridge = make_bridge();
1038 let result = bridge
1039 .call(
1040 "chrome.permissions.contains",
1041 &[Value::json(r#"{"permissions": ["tabs"]}"#)],
1042 )
1043 .unwrap();
1044 if let Value::Bool(has_perm) = result {
1045 assert!(has_perm); }
1047 }
1048
1049 #[test]
1050 fn permissions_request() {
1051 let bridge = make_bridge();
1052 let result = bridge
1053 .call(
1054 "chrome.permissions.request",
1055 &[Value::json(r#"{"permissions": ["history"]}"#)],
1056 )
1057 .unwrap();
1058 if let Value::Bool(granted) = result {
1059 assert!(granted); }
1061 }
1062
1063 #[test]
1064 fn permissions_unknown_method() {
1065 let bridge = make_bridge();
1066 let result = bridge.call("chrome.permissions.nonexistent", &[]);
1067 assert!(result.is_err());
1068 }
1069
1070 #[test]
1075 fn eval_with_csp_allowing() {
1076 let manifest = Manifest::parse(
1077 r#"{
1078 "name": "Permissive",
1079 "manifest_version": 3,
1080 "version": "1.0",
1081 "content_security_policy": {
1082 "extension_pages": "script-src 'self' 'unsafe-eval'"
1083 }
1084 }"#,
1085 )
1086 .unwrap();
1087 let bridge = ChromeExtBridge::new(
1088 manifest,
1089 AnalysisProfile::default(),
1090 ExtensionState::default(),
1091 );
1092
1093 let result = bridge.call("eval", &[Value::string("1+1")]);
1095 if let Err(e) = result {
1098 assert!(!e.contains("unsafe-eval"));
1099 }
1100
1101 let obs = bridge.take_observations();
1102 assert!(
1103 obs.iter()
1104 .any(|o| matches!(o, Observation::DynamicCodeExec { .. }))
1105 );
1106 }
1107
1108 #[test]
1109 fn function_constructor_blocked_by_csp() {
1110 let manifest = Manifest::parse(
1111 r#"{
1112 "name": "Strict",
1113 "manifest_version": 3,
1114 "version": "1.0",
1115 "content_security_policy": {
1116 "extension_pages": "script-src 'self'"
1117 }
1118 }"#,
1119 )
1120 .unwrap();
1121 let bridge = ChromeExtBridge::new(
1122 manifest,
1123 AnalysisProfile::default(),
1124 ExtensionState::default(),
1125 );
1126
1127 let result = bridge.call("Function", &[Value::string("return 1")]);
1128 assert!(result.is_err());
1129 assert!(result.unwrap_err().contains("unsafe-eval"));
1130 }
1131
1132 #[test]
1133 fn function_constructor_allowed() {
1134 let bridge = make_bridge();
1135 let _result = bridge.call("Function", &[Value::string("return 1")]);
1136 let obs = bridge.take_observations();
1138 assert!(
1139 obs.iter()
1140 .any(|o| matches!(o, Observation::DynamicCodeExec { .. }))
1141 );
1142 }
1143
1144 #[test]
1145 fn eval_code_preview_truncated() {
1146 let bridge = make_bridge();
1147 let long_code = "x".repeat(1000);
1148 let _result = bridge.call(
1149 "eval",
1150 &[Value::String(long_code.clone(), TaintLabel::default())],
1151 );
1152
1153 let obs = bridge.take_observations();
1154 let found = obs
1156 .iter()
1157 .any(|o| matches!(o, Observation::DynamicCodeExec { .. }));
1158 assert!(found);
1159
1160 let preview = obs.iter().find_map(|o| {
1162 if let Observation::DynamicCodeExec { code_preview, .. } = o {
1163 Some(code_preview.clone())
1164 } else {
1165 None
1166 }
1167 });
1168 assert!(preview.is_some());
1171 }
1172
1173 #[test]
1178 fn take_observations_drains_list() {
1179 let bridge = make_bridge();
1180
1181 bridge.call("chrome.tabs.query", &[]).unwrap();
1183 bridge.call("chrome.cookies.getAll", &[]).unwrap();
1184
1185 let obs1 = bridge.take_observations();
1187 assert!(!obs1.is_empty());
1188
1189 let obs2 = bridge.take_observations();
1191 assert!(obs2.is_empty());
1192 }
1193
1194 #[test]
1195 fn take_observations_returns_correct_count() {
1196 let bridge = make_bridge();
1197
1198 bridge.call("chrome.tabs.create", &[]).unwrap();
1199 bridge.call("chrome.tabs.update", &[]).unwrap();
1200 bridge.call("chrome.tabs.sendMessage", &[]).unwrap();
1201
1202 let obs = bridge.take_observations();
1203 assert!(obs.len() >= 3);
1204 }
1205
1206 #[test]
1207 fn observations_include_args() {
1208 let bridge = make_bridge();
1209
1210 bridge
1211 .call(
1212 "chrome.tabs.create",
1213 &[Value::json(r#"{"url": "https://example.com"}"#)],
1214 )
1215 .unwrap();
1216
1217 let obs = bridge.take_observations();
1218 let found = obs.iter().any(|o| {
1220 if let Observation::ApiCall { api, args, .. } = o {
1221 api == "chrome.tabs.create" && !args.is_empty()
1222 } else {
1223 false
1224 }
1225 });
1226 assert!(found);
1227 }
1228
1229 #[test]
1234 fn get_property_runtime_id() {
1235 let bridge = make_bridge();
1236 let result = bridge.get_property("chrome.runtime", "id").unwrap();
1237 if let Value::String(id, _) = result {
1238 assert!(!id.is_empty());
1239 }
1240 }
1241
1242 #[test]
1243 fn get_property_runtime_last_error() {
1244 let bridge = make_bridge();
1245 let result = bridge.get_property("chrome.runtime", "lastError").unwrap();
1246 assert!(matches!(result, Value::Null));
1247 }
1248
1249 #[test]
1250 fn get_property_unknown() {
1251 let bridge = make_bridge();
1252 let result = bridge.get_property("chrome.unknown", "property");
1253 assert!(result.is_err());
1254 }
1255
1256 #[test]
1257 fn get_property_unknown_property() {
1258 let bridge = make_bridge();
1259 let result = bridge.get_property("chrome.runtime", "unknownProperty");
1260 assert!(result.is_err());
1261 }
1262
1263 #[test]
1264 fn set_property_records_observation() {
1265 let bridge = make_bridge();
1266
1267 bridge
1268 .set_property("chrome.storage.local", "myKey", &Value::string("myValue"))
1269 .unwrap();
1270
1271 let obs = bridge.take_observations();
1272 let found = obs.iter().any(|o| {
1273 matches!(o,
1274 Observation::PropertyWrite { object, property, .. }
1275 if object == "chrome.storage.local" && property == "myKey"
1276 )
1277 });
1278 assert!(found);
1279 }
1280
1281 #[test]
1282 fn set_property_any_object() {
1283 let bridge = make_bridge();
1284 let result = bridge.set_property("some.object", "prop", &Value::Null);
1286 assert!(result.is_ok());
1287 }
1288
1289 #[test]
1294 fn bootstrap_js_returns_non_empty() {
1295 let bridge = make_bridge();
1296 let js = bridge.bootstrap_js();
1297 assert!(!js.is_empty());
1298 assert!(js.len() > 100);
1299 }
1300
1301 #[test]
1302 fn bootstrap_js_contains_chrome() {
1303 let bridge = make_bridge();
1304 let js = bridge.bootstrap_js();
1305 assert!(js.contains("chrome"));
1306 }
1307
1308 #[test]
1313 fn unknown_chrome_namespace() {
1314 let bridge = make_bridge();
1315 let result = bridge.call("chrome.totallyunknown.something", &[]);
1316 assert!(result.is_err());
1317 assert!(result.unwrap_err().contains("not defined"));
1318 }
1319
1320 #[test]
1321 fn unknown_top_level() {
1322 let bridge = make_bridge();
1323 let result = bridge.call("notchrome.something", &[]);
1324 assert!(result.is_err());
1325 }
1326
1327 #[test]
1328 fn empty_api_name() {
1329 let bridge = make_bridge();
1330 let result = bridge.call("", &[]);
1331 assert!(result.is_err());
1332 }
1333
1334 #[test]
1339 fn sink_detection_tabs_update() {
1340 let profile = AnalysisProfile::parse(
1341 r#"
1342[[sinks]]
1343api = "chrome.tabs.update"
1344dangerous_arg = 1
1345cwe = "CWE-601"
1346severity = "high"
1347"#,
1348 )
1349 .unwrap();
1350
1351 let bridge = make_bridge_with_profile(profile);
1352 bridge
1353 .call(
1354 "chrome.tabs.update",
1355 &[
1356 Value::string("1"),
1357 Value::json(r#"{"url": "https://evil.com"}"#),
1358 ],
1359 )
1360 .unwrap();
1361
1362 let obs = bridge.take_observations();
1363 let found = obs.iter().any(|o| {
1364 matches!(o,
1365 Observation::ApiCall { api, .. } if api.contains("SINK:")
1366 )
1367 });
1368 assert!(found);
1369 }
1370
1371 #[test]
1372 fn sink_detection_tabs_execute_script() {
1373 let profile = AnalysisProfile::parse(
1374 r#"
1375[[sinks]]
1376api = "chrome.tabs.executeScript"
1377cwe = "CWE-94"
1378"#,
1379 )
1380 .unwrap();
1381
1382 let bridge = make_bridge_with_profile(profile);
1383 bridge
1384 .call(
1385 "chrome.tabs.executeScript",
1386 &[Value::string("1"), Value::json(r#"{"code": "alert(1)"}"#)],
1387 )
1388 .unwrap();
1389
1390 let obs = bridge.take_observations();
1391 let found = obs.iter().any(|o| {
1392 matches!(o,
1393 Observation::ApiCall { api, .. } if api.contains("SINK:") && api.contains("CWE-94")
1394 )
1395 });
1396 assert!(found);
1397 }
1398
1399 #[test]
1400 fn sink_detection_scripting_execute_script() {
1401 let profile = AnalysisProfile::parse(
1402 r#"
1403[[sinks]]
1404api = "chrome.scripting.executeScript"
1405cwe = "CWE-94"
1406"#,
1407 )
1408 .unwrap();
1409
1410 let bridge = make_bridge_with_profile(profile);
1411 bridge.call("chrome.scripting.executeScript", &[]).unwrap();
1412
1413 let obs = bridge.take_observations();
1414 let found = obs.iter().any(|o| {
1415 matches!(o,
1416 Observation::ApiCall { api, .. } if api.contains("SINK:")
1417 )
1418 });
1419 assert!(found);
1420 }
1421
1422 #[test]
1423 fn sink_detection_scripting_insert_css() {
1424 let profile = AnalysisProfile::parse(
1425 r#"
1426[[sinks]]
1427api = "chrome.scripting.insertCSS"
1428cwe = "CWE-79"
1429"#,
1430 )
1431 .unwrap();
1432
1433 let bridge = make_bridge_with_profile(profile);
1434 bridge.call("chrome.scripting.insertCSS", &[]).unwrap();
1435
1436 let obs = bridge.take_observations();
1437 let found = obs.iter().any(|o| {
1438 matches!(o,
1439 Observation::ApiCall { api, .. } if api.contains("SINK:")
1440 )
1441 });
1442 assert!(found);
1443 }
1444
1445 #[test]
1446 fn source_detection_send_message() {
1447 let profile = AnalysisProfile::parse(
1448 r#"
1449[[sources]]
1450api = "chrome.runtime.sendMessage"
1451taint_id = 1
1452"#,
1453 )
1454 .unwrap();
1455
1456 let bridge = make_bridge_with_profile(profile);
1457 bridge
1458 .call("chrome.runtime.sendMessage", &[Value::Null])
1459 .unwrap();
1460
1461 let obs = bridge.take_observations();
1462 let found = obs.iter().any(|o| {
1463 matches!(o,
1464 Observation::ApiCall { api, .. } if api.contains("SOURCE:")
1465 )
1466 });
1467 assert!(found);
1468 }
1469
1470 #[test]
1471 fn no_sink_detection_for_unconfigured_api() {
1472 let profile = AnalysisProfile::default();
1473 let bridge = make_bridge_with_profile(profile);
1474
1475 bridge.call("chrome.tabs.update", &[]).unwrap();
1476
1477 let obs = bridge.take_observations();
1478 let found = obs.iter().any(|o| {
1479 matches!(o,
1480 Observation::ApiCall { api, .. } if api.contains("SINK:")
1481 )
1482 });
1483 assert!(!found);
1484 }
1485
1486 #[test]
1489 fn wasm_sandbox_detects_eval_via_bridge() {
1490 use jsdet_core::bridge::Bridge;
1491 use jsdet_core::{CompiledModule, PersistentSandbox, SandboxConfig};
1492 use std::sync::Arc;
1493
1494 let module = CompiledModule::new().unwrap();
1495 let manifest = Manifest::parse(r#"{"name":"T","manifest_version":3,"version":"1.0","background":{"service_worker":"sw.js"}}"#).unwrap();
1496 let profile = AnalysisProfile::default();
1497 let state = crate::state::ExtensionState::default_with_id("test");
1498 let bridge: Arc<dyn Bridge> =
1499 Arc::new(ChromeExtBridge::new(manifest.clone(), profile, state));
1500 let config = SandboxConfig::default();
1501
1502 let mut sb = PersistentSandbox::new(&module, bridge, &config).unwrap();
1503
1504 let bootstrap = crate::bootstrap::generate_bootstrap(&manifest);
1505 let handler = r#"
1506 chrome.runtime.onMessage.addListener(function(msg) {
1507 eval(msg.code);
1508 });
1509 "#
1510 .to_string();
1511
1512 sb.load(&[bootstrap, handler]).unwrap();
1513
1514 let obs = sb.eval_only(
1515 r#"
1516 chrome.runtime._fireOnMessage(
1517 {code: "alert(SLN_TEST_123)"},
1518 {tab: {id: 1}, id: "test"},
1519 function() {}
1520 );
1521 "#,
1522 );
1523
1524 eprintln!("Observations ({}):", obs.len());
1525 for o in &obs {
1526 eprintln!(" {:?}", o);
1527 }
1528
1529 let has_eval = obs.iter().any(|o| match o {
1530 jsdet_core::Observation::ApiCall { api, .. } => api == "eval",
1531 _ => false,
1532 });
1533 assert!(
1534 has_eval,
1535 "should detect eval() call. Got {} observations",
1536 obs.len()
1537 );
1538 }
1539}