1use petgraph::graph::NodeIndex;
25use vulnir::{
26 ConfidenceValue, Evidence, Producer, ProducerKind, Provenance, SourceLocation, VulnEdge,
27 VulnIRGraph, VulnNode, VulnProducer,
28};
29
30use crate::observation::{Observation, Value};
31use crate::sandbox::ExecutionResult;
32
33#[derive(Debug, Clone)]
38pub struct JsdetProducer {
39 producer: Producer,
40}
41
42impl JsdetProducer {
43 #[must_use]
45 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
46 Self {
47 producer: Producer::new(name, ProducerKind::Dynamic).with_version(version),
48 }
49 }
50
51 #[must_use]
53 pub fn default_producer() -> Self {
54 Self::new("jsdet-core", env!("CARGO_PKG_VERSION"))
55 }
56
57 fn create_provenance(&self, location: Option<SourceLocation>) -> Provenance {
59 let mut prov = Provenance::new(self.producer.clone());
60 prov.location = location;
61 prov
62 }
63
64 fn create_evidence(&self, details: impl Into<String>) -> Evidence {
66 Evidence::new(
67 self.producer.name.clone(),
68 details,
69 1.0, true,
71 )
72 }
73}
74
75impl VulnProducer for JsdetProducer {
76 fn producer(&self) -> Producer {
77 self.producer.clone()
78 }
79}
80
81impl Default for JsdetProducer {
82 fn default() -> Self {
83 Self::default_producer()
84 }
85}
86
87#[must_use]
107pub fn to_vulnir_graph(
108 result: &ExecutionResult,
109 producer: &JsdetProducer,
110) -> VulnIRGraph<VulnNode, VulnEdge> {
111 let mut graph = VulnIRGraph::new();
112 let mut node_indices: Vec<NodeIndex> = Vec::new();
113
114 for (idx, obs) in result.observations.iter().enumerate() {
116 let node_idx = observation_to_node(&mut graph, obs, idx, producer);
117 if let Some(idx) = node_idx {
118 node_indices.push(idx);
119 }
120 }
121
122 create_taint_edges(&mut graph, &result.observations, &node_indices, producer);
124
125 graph
126}
127
128fn observation_to_node(
133 graph: &mut VulnIRGraph<VulnNode, VulnEdge>,
134 obs: &Observation,
135 idx: usize,
136 producer: &JsdetProducer,
137) -> Option<NodeIndex> {
138 match obs {
139 Observation::DynamicCodeExec {
141 source,
142 code_preview,
143 } => {
144 let source_type = match source {
145 crate::observation::DynamicCodeSource::Eval => "eval",
146 crate::observation::DynamicCodeSource::Function => "function-constructor",
147 crate::observation::DynamicCodeSource::SetTimeoutString => "settimeout-string",
148 crate::observation::DynamicCodeSource::SetIntervalString => "setinterval-string",
149 crate::observation::DynamicCodeSource::ImportScripts => "importscripts",
150 };
151
152 let node = VulnNode::Capability {
153 id: format!("dynamic-code-exec-{idx}"),
154 resource: "code-execution".to_string(),
155 permission: "execute".to_string(),
156 target: Some(code_preview.chars().take(100).collect()),
157 arguments: vec![source_type.to_string()],
158 duration_ns: None,
159 provenance: vec![producer.create_provenance(Some(SourceLocation {
160 file: None,
161 artifact: None,
162 line: None,
163 column: None,
164 }))],
165 metadata: Default::default(),
166 };
167
168 Some(graph.add_node(node))
169 }
170
171 Observation::NetworkRequest {
173 url,
174 method,
175 headers: _,
176 body: _,
177 } => {
178 let node = VulnNode::Capability {
179 id: format!("network-request-{idx}"),
180 resource: "network".to_string(),
181 permission: method.to_lowercase(),
182 target: Some(url.clone()),
183 arguments: vec![],
184 duration_ns: None,
185 provenance: vec![producer.create_provenance(Some(SourceLocation {
186 file: None,
187 artifact: None,
188 line: None,
189 column: None,
190 }))],
191 metadata: Default::default(),
192 };
193
194 Some(graph.add_node(node))
195 }
196
197 Observation::ApiCall {
199 api,
200 args,
201 result: _,
202 } => {
203 if is_code_execution(api) {
204 let code_preview = args
206 .first()
207 .and_then(|v| v.as_str())
208 .map(|s| s.chars().take(100).collect())
209 .unwrap_or_else(|| "<empty>".to_string());
210
211 let node = VulnNode::Capability {
212 id: format!("dynamic-code-exec-{idx}"),
213 resource: "code-execution".to_string(),
214 permission: "execute".to_string(),
215 target: Some(code_preview),
216 arguments: vec![api.clone()],
217 duration_ns: None,
218 provenance: vec![producer.create_provenance(Some(SourceLocation {
219 file: None,
220 artifact: None,
221 line: None,
222 column: None,
223 }))],
224 metadata: Default::default(),
225 };
226
227 Some(graph.add_node(node))
228 } else if is_module_import(api) {
229 let module_name = extract_module_name(api, args);
231
232 let node = VulnNode::TrustBoundary {
233 id: format!("module-import-{idx}"),
234 description: format!("Module import: {module_name}"),
235 source_type: "module-import".to_string(),
236 confidence: ConfidenceValue::from(1.0),
237 taint_id: None,
238 created_ns: None,
239 provenance: vec![producer.create_provenance(Some(SourceLocation {
240 file: None,
241 artifact: None,
242 line: None,
243 column: None,
244 }))],
245 metadata: Default::default(),
246 };
247
248 Some(graph.add_node(node))
249 } else if is_filesystem_operation(api) {
250 let permission = if api.contains("read") || api.contains("readdir") {
252 "read"
253 } else if api.contains("write") {
254 "write"
255 } else {
256 "access"
257 };
258
259 let target = args.first().and_then(|v| v.as_str()).map(String::from);
260
261 let node = VulnNode::Capability {
262 id: format!("filesystem-{idx}"),
263 resource: "filesystem".to_string(),
264 permission: permission.to_string(),
265 target,
266 arguments: vec![api.clone()],
267 duration_ns: None,
268 provenance: vec![producer.create_provenance(Some(SourceLocation {
269 file: None,
270 artifact: None,
271 line: None,
272 column: None,
273 }))],
274 metadata: Default::default(),
275 };
276
277 Some(graph.add_node(node))
278 } else {
279 None
280 }
281 }
282
283 Observation::DomMutation {
285 kind,
286 target,
287 detail,
288 } => {
289 let _description = format!("DOM {kind:?} on {target}: {detail}");
290
291 let node = VulnNode::Capability {
292 id: format!("dom-mutation-{idx}"),
293 resource: "dom-manipulation".to_string(),
294 permission: "modify".to_string(),
295 target: Some(target.clone()),
296 arguments: vec![format!("{kind:?}"), detail.clone()],
297 duration_ns: None,
298 provenance: vec![producer.create_provenance(Some(SourceLocation {
299 file: None,
300 artifact: None,
301 line: None,
302 column: None,
303 }))],
304 metadata: Default::default(),
305 };
306
307 Some(graph.add_node(node))
308 }
309
310 Observation::CookieAccess {
312 operation,
313 name,
314 value: _,
315 } => {
316 let description = format!("Cookie {operation:?}: {name}");
317
318 let node = VulnNode::TrustBoundary {
319 id: format!("cookie-access-{idx}"),
320 description,
321 source_type: "cookie-access".to_string(),
322 confidence: ConfidenceValue::from(1.0),
323 taint_id: None,
324 created_ns: None,
325 provenance: vec![producer.create_provenance(Some(SourceLocation {
326 file: None,
327 artifact: None,
328 line: None,
329 column: None,
330 }))],
331 metadata: Default::default(),
332 };
333
334 Some(graph.add_node(node))
335 }
336
337 Observation::WasmInstantiation {
339 module_size,
340 import_names,
341 export_names,
342 } => {
343 let node = VulnNode::Capability {
344 id: format!("wasm-instantiation-{idx}"),
345 resource: "webassembly".to_string(),
346 permission: "instantiate".to_string(),
347 target: Some(format!("{module_size} bytes")),
348 arguments: vec![
349 format!("imports: {:?}", import_names),
350 format!("exports: {:?}", export_names),
351 ],
352 duration_ns: None,
353 provenance: vec![producer.create_provenance(Some(SourceLocation {
354 file: None,
355 artifact: None,
356 line: None,
357 column: None,
358 }))],
359 metadata: Default::default(),
360 };
361
362 Some(graph.add_node(node))
363 }
364
365 Observation::ContextMessage {
367 from_context,
368 to_context,
369 payload,
370 } => {
371 let description = format!("Message from {from_context} to {to_context}: {payload}");
372
373 let node = VulnNode::TrustBoundary {
374 id: format!("context-message-{idx}"),
375 description,
376 source_type: "cross-context".to_string(),
377 confidence: ConfidenceValue::from(1.0),
378 taint_id: None,
379 created_ns: None,
380 provenance: vec![producer.create_provenance(Some(SourceLocation {
381 file: None,
382 artifact: None,
383 line: None,
384 column: None,
385 }))],
386 metadata: Default::default(),
387 };
388
389 Some(graph.add_node(node))
390 }
391
392 _ => None,
394 }
395}
396
397fn create_taint_edges(
399 graph: &mut VulnIRGraph<VulnNode, VulnEdge>,
400 observations: &[Observation],
401 node_indices: &[NodeIndex],
402 producer: &JsdetProducer,
403) {
404 for obs in observations {
405 if let Observation::ApiCall { api, args, .. } = obs {
406 let tainted_positions: Vec<usize> = args
408 .iter()
409 .enumerate()
410 .filter(|(_, v)| is_value_tainted(v))
411 .map(|(idx, _)| idx)
412 .collect();
413
414 if !tainted_positions.is_empty() && !node_indices.is_empty() {
415 let evidence = vec![producer.create_evidence(format!(
421 "Tainted data reached sink '{api}' at argument positions {:?}",
422 tainted_positions
423 ))];
424
425 if node_indices.len() >= 2 {
428 let source_idx = node_indices[node_indices.len() - 2];
429 let sink_idx = node_indices[node_indices.len() - 1];
430
431 let edge = VulnEdge::TaintReach {
432 path: format!("{api}<-arg[{tainted_positions:?}]"),
433 confidence: ConfidenceValue::from(1.0),
434 observed_dynamically: true,
435 transform_chain: vec![],
436 evidence,
437 };
438
439 graph.add_edge(source_idx, sink_idx, edge);
440 }
441 }
442 }
443 }
444}
445
446fn is_value_tainted(value: &Value) -> bool {
448 match value {
449 Value::String(_, label) | Value::Json(_, label) => label.is_tainted(),
450 _ => false,
451 }
452}
453
454fn is_code_execution(api: &str) -> bool {
456 api == "eval"
457 || api == "Function"
458 || api == "setTimeout"
459 || api == "setInterval"
460 || api == "importScripts"
461 || api.contains("executeScript")
462 || api.contains("execScript")
463}
464
465fn is_module_import(api: &str) -> bool {
467 api == "require"
468 || api == "import"
469 || api.starts_with("module.require")
470 || api.contains("__webpack_require__")
471 || api.contains("dynamicImport")
472}
473
474fn extract_module_name(api: &str, args: &[Value]) -> String {
476 if let Some(first_arg) = args.first()
477 && let Some(s) = first_arg.as_str()
478 {
479 return s.to_string();
480 }
481 api.to_string()
482}
483
484fn is_filesystem_operation(api: &str) -> bool {
486 api.starts_with("fs.")
487 || api.starts_with("node:fs")
488 || api.contains("readFile")
489 || api.contains("writeFile")
490 || api.contains("readdir")
491 || api.contains("unlink")
492 || api.contains("mkdir")
493 || api.contains("rmdir")
494}
495
496pub trait ToVulnIR {
498 fn to_vulnir_graph(&self) -> VulnIRGraph<VulnNode, VulnEdge>;
502
503 fn to_vulnir_graph_with_producer(
505 &self,
506 producer: &JsdetProducer,
507 ) -> VulnIRGraph<VulnNode, VulnEdge>;
508}
509
510impl ToVulnIR for ExecutionResult {
511 fn to_vulnir_graph(&self) -> VulnIRGraph<VulnNode, VulnEdge> {
512 let producer = JsdetProducer::default_producer();
513 to_vulnir_graph(self, &producer)
514 }
515
516 fn to_vulnir_graph_with_producer(
517 &self,
518 producer: &JsdetProducer,
519 ) -> VulnIRGraph<VulnNode, VulnEdge> {
520 to_vulnir_graph(self, producer)
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::observation::{DynamicCodeSource, Observation, TaintLabel, Value};
528 use vulnir::VulnIRGraphExt;
529
530 #[test]
531 fn eval_produces_capability_node() {
532 let result = ExecutionResult {
533 observations: vec![Observation::DynamicCodeExec {
534 source: DynamicCodeSource::Eval,
535 code_preview: "console.log('test')".to_string(),
536 }],
537 scripts_executed: 1,
538 errors: vec![],
539 duration_us: 1000,
540 timed_out: false,
541 };
542
543 let graph = result.to_vulnir_graph();
544
545 assert_eq!(graph.node_count(), 1);
546 assert_eq!(graph.edge_count(), 0);
547
548 let attack_surface = graph.attack_surface();
550 assert!(attack_surface.is_empty()); }
552
553 #[test]
554 fn require_produces_trust_boundary_node() {
555 let result = ExecutionResult {
556 observations: vec![Observation::ApiCall {
557 api: "require".to_string(),
558 args: vec![Value::string("fs")],
559 result: Value::Null,
560 }],
561 scripts_executed: 1,
562 errors: vec![],
563 duration_us: 1000,
564 timed_out: false,
565 };
566
567 let graph = result.to_vulnir_graph();
568
569 assert_eq!(graph.node_count(), 1);
570 assert_eq!(graph.edge_count(), 0);
571
572 let attack_surface = graph.attack_surface();
574 assert_eq!(attack_surface.len(), 1);
575 }
576
577 #[test]
578 fn network_and_eval_produces_taint_reach_edge() {
579 let result = ExecutionResult {
580 observations: vec![
581 Observation::NetworkRequest {
582 url: "https://evil.com/payload".to_string(),
583 method: "GET".to_string(),
584 headers: vec![],
585 body: None,
586 },
587 Observation::ApiCall {
588 api: "eval".to_string(),
589 args: vec![Value::tainted_string(
590 "console.log('pwned')",
591 TaintLabel::new(1),
592 )],
593 result: Value::Null,
594 },
595 ],
596 scripts_executed: 1,
597 errors: vec![],
598 duration_us: 1000,
599 timed_out: false,
600 };
601
602 let graph = result.to_vulnir_graph();
603
604 assert_eq!(graph.node_count(), 2);
606
607 assert_eq!(graph.edge_count(), 1);
609 }
610
611 #[test]
612 fn empty_result_produces_empty_graph() {
613 let result = ExecutionResult {
614 observations: vec![],
615 scripts_executed: 0,
616 errors: vec![],
617 duration_us: 0,
618 timed_out: false,
619 };
620
621 let graph = result.to_vulnir_graph();
622
623 assert_eq!(graph.node_count(), 0);
624 assert_eq!(graph.edge_count(), 0);
625 }
626
627 #[test]
628 fn filesystem_operations_produce_capability_nodes() {
629 let result = ExecutionResult {
630 observations: vec![Observation::ApiCall {
631 api: "fs.readFileSync".to_string(),
632 args: vec![Value::string("/etc/passwd")],
633 result: Value::Null,
634 }],
635 scripts_executed: 1,
636 errors: vec![],
637 duration_us: 1000,
638 timed_out: false,
639 };
640
641 let graph = result.to_vulnir_graph();
642
643 assert_eq!(graph.node_count(), 1);
644
645 let caps = graph.reachable_capabilities(NodeIndex::new(0));
647 assert_eq!(caps.len(), 1);
648 }
649
650 #[test]
651 fn jsdet_producer_trait_implementation() {
652 let producer = JsdetProducer::new("test-producer", "1.0.0");
653 let p = producer.producer();
654
655 assert_eq!(p.name, "test-producer");
656 assert_eq!(p.version, Some("1.0.0".to_string()));
657 assert_eq!(p.kind, ProducerKind::Dynamic);
658 }
659
660 #[test]
661 fn custom_producer_used_in_graph() {
662 let result = ExecutionResult {
663 observations: vec![Observation::DynamicCodeExec {
664 source: DynamicCodeSource::Function,
665 code_preview: "return 1".to_string(),
666 }],
667 scripts_executed: 1,
668 errors: vec![],
669 duration_us: 1000,
670 timed_out: false,
671 };
672
673 let producer = JsdetProducer::new("custom-scanner", "2.0.0");
674 let graph = result.to_vulnir_graph_with_producer(&producer);
675
676 assert_eq!(graph.node_count(), 1);
677 }
678}